JDBC
Overview
WhatisJDBC?
JDBC stands for "Java DataBase Connectivity". It is an API (Application Programming Interface), which consists of a set of Java classes, interfaces and exceptions and a specification to which both JDBC driver vendors and JDBC developers (like you) adhere when developing applications.
JDBC stands for "Java DataBase Connectivity". It is an API (Application Programming Interface), which consists of a set of Java classes, interfaces and exceptions and a specification to which both JDBC driver vendors and JDBC developers (like you) adhere when developing applications.
JDBC is a very popular data access standard. RDBMS (Relational Database Management Systems) or third-party vendors develop drivers, which adhere to the JDBC specification. Since the drivers adhered to JDBC specification, the JDBC application developers can replace one driver for their application with another better one without having to rewrite their application. If they had used some proprietary API provided by some RDBMS vendor, they will not have been able to change the driver and/or database without having to rewrite the complete application.
Call-level interfaces such as JDBC are programming interfaces allowing external access to SQL database manipulation and update commands. They allow the integration of SQL calls into a general programming environment by providing library routines, which interface with the database. In particular, Java based JDBC has a rich collection of routines which make such an interface extremely simple and intuitive.
Here is an easy way of visualizing what happens in a call level interface: You are writing a normal Java program. Somewhere in the program, you need to interact with a database. Using standard library routines, you open a connection to the database. You then use JDBC to send your SQL code to the database, and process the results that are returned. When you are done, you close the connection.
whyuseJDBC?
JDBC is there only to help you (a Java developer) develop data access applications without having to learn and use proprietary APIs provided by different RDBMS vendors. You just have to learn JDBC and then you can be sure that you'll be able to develop data access applications which can access different RDBMS using different JDBC drivers.
JDBC is there only to help you (a Java developer) develop data access applications without having to learn and use proprietary APIs provided by different RDBMS vendors. You just have to learn JDBC and then you can be sure that you'll be able to develop data access applications which can access different RDBMS using different JDBC drivers.
JDBC-database interaction
JDBC Architecture is divided into 2 parts:
- JDBC API (java.sql & javax.sql packages)
- JDBC Driver Types
JDBC API
The JDBC API is available in the java.sql and javax.sql packages. Following are important JDBC classes, interfaces and exceptions in the java.sql package:
The JDBC API is available in the java.sql and javax.sql packages. Following are important JDBC classes, interfaces and exceptions in the java.sql package:
- DriverManager - Loads JDBC drivers in memory. Can also be used to open connections to a data source.
- Connection - Represents a connection with a data source. Is also used for creating
Statement
,PreparedStatement
andCallableStatement
objects. - Statement - Represents static SQL statement. Can be used to retrieve
ResultSet
object/s. - PreparedStatement - Higher performance alternative to
Statement
object represents a precompiled SQL statement. - CallableStatement - Represents a stored procedure. Can be used to execute stored procedures in a RDBMS, which supports them.
- ResultSet - Represents a database result set generated by using a SELECT SQL statement.
- SQLException - An exception class, which encapsulates database base access errors.
JDBC Driver Types
Type 1 :JDBC-ODBC bridge drivers
Type 1 drivers use a bridge technology to connect a Java client to an ODBC database system. The JDBC-ODBC Bridge from Sun and InterSolv is the only existing example of a Type 1 driver. Type 1 drivers require some sort of non-Java software to be installed on the machine running your code, and they are implemented using native code.
Type 2: Native-API partly Java drivers
Type 2 drivers use a native code library to access a database, wrapping a thin layer of Java around the native library. For example, with Oracle databases, the native access might be through the Oracle Call Interface (OCI) libraries that were originally designed for C/C++ programmers. Type 2 drivers are implemented with native code, so they may perform better than all-Java drivers, but they also add an element of risk, as a defect in the native code can crash the Java Virtual Machine.
Type 3: Net-protocol All-Java drivers
Type 3 drivers define a generic network protocol that interfaces with a piece of custom middleware. The middleware component might use any other type of driver to provide the actual database access. BEA's WebLogic product line (formerly known as WebLogic Tengah and before that as jdbcKona/T3) is an example. These drivers are especially useful for applet deployment, since the actual JDBC classes can be written entirely in Java and downloaded by the client on the fly.
Type 4: Native-protocol All-Java drivers
Type 4 drivers are written entirely in Java. They understand database-specific networking protocols and can access the database directly without any additional software. These drivers are also well suited for applet programming, provided that the Java security manager allows TCP/IP connections to the database server.
When you are selecting a driver, you need to balance speed, reliability, and portability. Different applications have different needs. A standalone, GUI-intensive program that always runs on a Windows NT system will benefit from the additional speed of a Type 2, native-code driver. An applet might need to use a Type 3 driver to get around a firewall. A servlet that is deployed across multiple platforms might require the flexibility of a Type 4 driver.SUN encourages to develop and use type 4 drivers in your applications.
JDBC URLs
A JDBC driver uses a JDBC URL to identify and connect to a particular database. These URLs are generally of the form:
jdbc:driver:databasename
The actual standard is quite fluid, however, as different databases require different information to connect successfully. For example, the Oracle JDBC-Thin driver uses a URL of the form:
jdbc:oracle:thin:@site:port:database
while the JDBC-ODBC Bridge uses:
jdbc:odbc:datasource;odbcoptions
The only requirement is that a driver be able to recognize its own URLs.
The first thing to do, of course, is to install Java, JDBC and the DBMS on your working machines. Since we want to interface with an Oracle database, we would need a driver for this specific database as well.
The JDBC-ODBC Bridge
The JDBC-ODBC Bridge ships with JDK 1.1 and the Java 2 SDK for Windows and Solaris systems. The bridge provides an interface between JDBC and database drivers written using Microsoft's Open DataBase Connectivity (ODBC) API. The bridge was originally written to allow the developer community to get up and running quickly with JDBC. Since the bridge makes extensive use of native method calls, it is not recommended for long-term or high-volume deployment.
The bridge is not a required component of the Java SDK, so most web browsers or other runtime environments do not support it. Using the bridge in an applet requires a browser with a JVM that supports the JDBC-ODBC Bridge, as well as a properly configured ODBC driver and data source on the client side.
The JDBC URL subprotocol odbc has been reserved for the bridge. Like most JDBC URLs, it allows programs to encode extra information about the connection. ODBC URLs are of the form:
jdbc:odbc:datasourcename[;attribute-name=attribute-value]*
For instance, a JDBC URL pointing to an ODBC data source named
companydb
with the CacheSize
attribute set to 10 looks like this:jdbc:odbc:companydb;CacheSize=10
Establishing A Connection
As we said earlier, before a database can be accessed, a connection must be opened between our program(client) and the database(server). This involves two steps:
- Load the vendor specific driver
Why would we need this step? To ensure portability and code reuse, the API was designed to be as independent of the version or the vendor of a database as possible. Since different DBMS's have different behavior, we need to tell the driver manager which DBMS we wish to use, so that it can invoke the correct driver.
An Oracle driver is loaded using the following code:
Class.forName("oracle.jdbc.driver.OracleDriver")
- Make the connection
The
java.sql.Connection
object, which encapsulates a single connection to a particular database, forms the basis of all JDBC data handling code. An application can maintain multiple connections, up to the limits imposed by the database system itself. Once the driver is loaded and ready for a connection to be made, you may create an instance of a Connection object using: The
DriverManager.getConnection( )
method as:Connection con = DriverManager.getConnection("url", "user", "password");
You pass three arguments to
getConnection( )
: a JDBC URL, a database username, and a password. For databases that don't require explicit logins, the user and password strings should be left blank. When the method is called, the DriverManager
queries each registered driver, asking if it understands the URL. If a driver recognizes the URL, it returns a Connection
object. The
getConnection( )
method has two other variants that are less frequently used. One variant takes a single String argument and tries to create a connection to that JDBC URL without a username or password, or with a username and password embedded in the URL itself. Connection con = DriverManager.getConnection(
"jdbc:oracle:thin:@dbaprod1:1544:SHR1_PRD", username, passwd);
The first string is the URL for the database including the protocol (jdbc), the vendor (oracle), the driver (thin), the server (dbaprod1), the port number (1521), and a server instance (SHR1_PRD). The username and passwd are your username and password, the same as you would enter into SQLPLUS to access your account.
The connection returned in the last step is an open connection which we will use to pass SQL statements to the database. When a
Connection
has outlived its usefulness, you should be sure to explicitly close it by calling its close( )
method. This frees up any memory being used by the object, and, more importantly, it releases any other database resources the connection may be holding on to. These resources (cursors, handles, and so on) can be much more valuable than a few bytes of memory, as they are often quite limited. This is particularly important in applications such as servlets that might need to create and destroy thousands of JDBC connections between restarts. Because of the way some JDBC drivers are designed, it is not safe to rely on Java's garbage collection to remove unneeded JDBC connections.In this code snippet, con is an open connection, and we will use it below.
Statements
Once you have created a
Connection
, you can begin using it to execute SQL statements. This is usually done via Statement
objects. There are actually three kinds of statements in JDBC:Statement
Represents a basic SQL statement
PreparedStatement
Represents a precompiled SQL statement, which can offer improved performance
CallableStatement
Allows JDBC programs complete access to stored procedures within the database itself
Statement
A JDBC Statement object is used to send your SQL statements to the DBMS, and should not to be confused with an SQL statement. A JDBC Statement object is associated with an open connection, and not any single SQL Statement. You can think of a JDBC Statement object as a channel sitting on a connection, and passing one or more of your SQL statements (which you ask it to execute) to the DBMS.
An active connection is needed to create a Statement object. To get a
Statement
object, call the createStatement( )
method of a Connection
: Statement stmt = con.createStatement() ;
At this point, a Statement object exists, but it does not have an SQL statement to pass on to the DBMS.
Once you have created a
Statement
, use it to execute SQL statements. A statement can either be a query that returns results or an operation that manipulates the database in some way. If you are performing a query, use the executeQuery( )
method of the Statement
object:ResultSet rs = stmt.executeQuery("SELECT * FROM CUSTOMERS");
Here we've used
executeQuery()
to run a SELECT
statement. This call returns a ResultSet
object that contains the results of the queryStatement
also provides an executeUpdate( )
method, for running SQL statements that don't return results, such as the UPDATE
and DELETE
statements. executeUpdate( )
returns an integer that indicates the number of rows in the database that were altered.If you don't know whether a SQL statement is going to return results (such as when the user is entering the statement in a form field), you can use the
execute( )
method of Statement
. This method returns true
if there is a result associated with the statement. In this case, the ResultSet
can be retrieved using the getResultSet( )
method and the number of updated rows can be retrieved using getUpdateCount( )
:Statement unknownSQL = con.createStatement( );
if(unknownSQL.execute(sqlString)) {
ResultSet rs = unknownSQL.getResultSet( );
// Display the results
}
else {
System.out.println("Rows updated: " + unknownSQL.getUpdateCount( ));
}
It is important to remember that a Statement
object represents a single SQL statement. A call to executeQuery( )
, executeUpdate( )
, or execute( )
implicitly closes any active ResultSet
associated with the Statement
. In other words, you need to be sure you are done with the results from a query before you execute another query with the same Statement
object. If your application needs to execute more than one simultaneous query, you need to use multiple Statement
objects. As a general rule, calling the close( )
method of any JDBC object also closes any dependent objects, such as a Statement
generated by a Connection
or a ResultSet
generated by a Statement
, but well-written JDBC code closes everything explicitly.
Example: A Simple JDBC Example
import java.sql.*;
public class JDBCSample {
public static void main(java.lang.String[] args) {
try {
// This is where we load the driver
Class.forName("sun.jdbc.odbc.JdbcOdbcDriver");
}
catch (ClassNotFoundException e) {
System.out.println("Unable to load Driver Class");
return;
}
try {
// All database access is within a try/catch block. Connect to database,
// specifying particular database, username, and password
Connection con = DriverManager.getConnection("jdbc:odbc:companydb",
"", "");
// Create and execute an SQL Statement
Statement stmt = con.createStatement( );
ResultSet rs = stmt.executeQuery("SELECT FIRST_NAME FROM EMPLOYEES");
// Display the SQL Results
while(rs.next( )) {
System.out.println(rs.getString("FIRST_NAME"));
}
// Make sure our database resources are released
rs.close( );
stmt.close( );
con.close( );
}
catch (SQLException se) {
// Inform user of any SQL errors
System.out.println("SQL Exception: " + se.getMessage( ));
}
}
}
Example starts out by loading a JDBC driver class (in this case, Sun's JDBC-ODBC Bridge). Then it creates a database connection, represented by a Connection
object, using that driver. With the database connection, we can create a Statement
object to represent an SQL statement. Executing an SQL statement produces a ResultSet
that contains the results of a query. The program displays the results and then cleans up the resources it has used. If an error occurs, a SQLException
is thrown, so our program traps that exception and displays some of the information it encapsulates
Creating JDBC PreparedStatement
Prepared Statements
The
PreparedStatement
object is a close relative of the Statement
object. Both accomplish roughly the same thing: running SQL statements. PreparedStatement
, however, allows you to precompile your SQL and run it repeatedly, adjusting specific parameters as necessary. Since processing SQL strings is a large part of a database's overhead, getting compilation out of the way at the start can significantly improve performance. As with Statement
, you create a PreparedStatement
object from a Connection
object. In this case, though, the SQL is specified at creation instead of execution, using the prepareStatement( )
method of Connection
:PreparedStatement pstmt = con.prepareStatement(
"INSERT INTO EMPLOYEES (NAME, PHONE) VALUES (?, ?)");
This SQL statement inserts a new row into the
EMPLOYEES
table, setting the NAME
and PHONE
columns to certain values. Since the whole point of a PreparedStatement
is to be able to execute the statement repeatedly, we don't specify values in the call to prepareStatement( )
, but instead use question marks (?
) to indicate parameters for the statement. To actually run the statement, we specify values for the parameters and then execute the statement:pstmt.clearParameters( );
pstmt.setString(1, "Jimmy Adelphi");
pstmt.setString(2, "201 555-7823");
pstmt.executeUpdate( );
Before setting parameters, we clear out any previously specified parameters with the
clearParameters( )
method. Then we can set the value for each parameter (indexed from 1 to the number of question marks) using the setString( )
method. PreparedStatement
defines numerous setXXX( )
methods for specifying different types of parameters; Finally, we use the executeUpdate( )
method to run the SQL.Sometimes, it is more convenient or more efficient to use a PreparedStatement object for sending SQL statements to the DBMS. The main feature which distinguishes it from its superclass Statement, is that unlike Statement, it is given an SQL statement right when it is created. This SQL statement is then sent to the DBMS right away, where it is compiled. Thus, in effect, a PreparedStatement is associated as a channel with a connection and a compiled SQL statement.
The advantage offered is that if you need to use the same, or similar query with different parameters multiple times, the statement can be compiled and optimized by the DBMS just once. Contrast this with a use of a normal Statement where each use of the same SQL statement requires a compilation all over again.
PreparedStatements are also created with a Connection method. The following snippet shows how to create a parameterized SQL statement with three input parameters:
PreparedStatement prepareUpdatePrice = con.prepareStatement(
"UPDATE Sells SET price = ? WHERE bar = ? AND beer = ?");
Before we can execute a PreparedStatement, we need to supply values for the parameters. This can be done by calling one of the setXXX methods defined in the class PreparedStatement. Most often used methods are setInt, setFloat, setDouble, setString etc. You can set these values before each execution of the prepared statement.
Continuing the above example, we would write:
prepareUpdatePrice.setInt(1, 3);
prepareUpdatePrice.setString(2, "Bar Of Foo");
prepareUpdatePrice.setString(3, "BudLite");
Executing CREATE/INSERT/UPDATE Statements
Executing SQL statements in JDBC varies depending on the ``intention'' of the SQL statement. DDL (data definition language) statements such as table creation and table alteration statements, as well as statements to update the table contents, are all executed using the method executeUpdate. Notice that these commands change the state of the database, hence the name of the method contains ``Update''.
The following snippet has examples of executeUpdate statements.
Statement stmt = con.createStatement();
stmt.executeUpdate("CREATE TABLE Sells " +
"(bar VARCHAR2(40), beer VARCHAR2(40), price REAL)" );
stmt.executeUpdate("INSERT INTO Sells " +
"VALUES ('Bar Of Foo', 'BudLite', 2.00)" );
String sqlString = "CREATE TABLE Bars " +
"(name VARCHAR2(40), address VARCHAR2(80), license INT)" ;
stmt.executeUpdate(sqlString);
Since the SQL statement will not quite fit on one line on the page, we have split it into two strings concatenated by a plus sign(+) so that it will compile. Pay special attention to the space following "INSERT INTO Sells" to separate it in the resulting string from "VALUES". Note also that we are reusing the same Statement object rather than having to create a new one.
When executeUpdate is used to call DDL statements, the return value is always zero, while data modification statement executions will return a value greater than or equal to zero, which is the number of tuples affected in the relation.
While working with a PreparedStatement, we would execute such a statement by first plugging in the values of the parameters (as seen above), and then invoking the executeUpdate on it.
int n = prepareUpdatePrice.executeUpdate() ;
Executing SELECT Statements
As opposed to the previous section statements, a query is expected to return a set of tuples as the result, and not change the state of the database. Not surprisingly, there is a corresponding method called executeQuery, which returns its results as a ResultSet object:
String bar, beer ;
float price ;
ResultSet rs = stmt.executeQuery("SELECT * FROM Sells");
while ( rs.next() ) {
bar = rs.getString("bar");
beer = rs.getString("beer");
price = rs.getFloat("price");
System.out.println(bar + " sells " + beer + " for " + price + " Dollars.");
}
The bag of tuples resulting from the query are contained in the variable rs which is an instance of ResultSet. A set is of not much use to us unless we can access each row and the attributes in each row. The ResultSet provides a cursor to us, which can be used to access each row in turn. The cursor is initially set just before the first row. Each invocation of the method next causes it to move to the next row, if one exists and return true, or return false if there is no remaining row.
We can use the getXXX method of the appropriate type to retrieve the attributes of a row. In the previous example, we used getString and getFloat methods to access the column values. Notice that we provided the name of the column whose value is desired as a parameter to the method. Also note that the VARCHAR2 type bar, beer have been converted to Java String, and the REAL to Java float.
Equivalently, we could have specified the column number instead of the column name, with the same result. Thus the relevant statements would be:
bar = rs.getString(1);
price = rs.getFloat(3);
beer = rs.getString(2);
While working with a PreparedStatement, we would execute a query by first plugging in the values of the parameters, and then invoking the executeQuery on it.
ResultSet rs = prepareUpdatePrice.executeQuery() ;
Results
When an SQL query executes, the results form a pseudo-table that contains all rows that fit the query criteria. For instance, here's a textual representation of the results of the query string "SELECT NAME, CUSTOMER_ID, PHONE FROM CUSTOMERS":
NAME CUSTOMER_ID PHONE
-------------------------------- ----------- -------------------
Jane Markham 1 617 555-1212
Louis Smith 2 617 555-1213
Woodrow Lang 3 508 555-7171
Dr. John Smith 4 (011) 42 323-1239
This kind of textual representation is not very useful for Java programs. Instead, JDBC uses the
java.sql.ResultSet
interface to encapsulate the query results as Java primitive types and objects. You can think of a ResultSet
as an object that represents an underlying table of query results, where you use method calls to navigate between rows and retrieve particular column values.A Java program might handle the previous query as follows:
Statement stmt = con.createStatement( );
ResultSet rs = stmt.executeQuery(
"SELECT NAME, CUSTOMER_ID, PHONE FROM CUSTOMERS");
while(rs.next( )) {
System.out.print("Customer #" + rs.getString("CUSTOMER_ID"));
System.out.print(", " + rs.getString("NAME"));
System.out.println(", is at " + rs.getString("PHONE");
}
rs.close( );
stmt.close( );
Here's the resulting output:
Customer #1, Jane Markham, is at 617 555-1212
Customer #2, Louis Smith, is at 617 555-1213
Customer #3, Woodrow Lang, is at 508 555-7171
Customer #4, Dr. John Smith, is at (011) 42 323-1239
The code loops through each row of the
ResultSet
using the next( )
method. When you start working with a ResultSet
, you are positioned before the first row of results. That means you have to call next( )
once just to access the first row. Each time you call next( )
, you move to the next row. If there are no more rows to read, next( )
returns false
. Note that with the JDBC 1.0 ResultSet
, you can only move forward through the results and, since there is no way to go back to the beginning, you can read them only once. The JDBC 2.0 ResultSet
, which we discuss later, overcomes these limitations.Individual column values are read using the
getString( )
method. getString( )
is one of a family of getXXX( )
methods, each of which returns data of a particular type. There are two versions of each getXXX( )
method: one that takes the case-insensitive String
name of the column to be read (e.g., "PHONE"
, "CUSTOMER_ID"
) and one that takes a SQL-style column index. Note that column indexes run from 1 to n, unlike Java array indexes, which run from 0 to n-1, where n is the number of columns.The most important
getXXX( )
method is getObject( )
, which can return any kind of data packaged in an object wrapper. For example, calling getObject( )
on an integer field returns an Integer
object, while calling it on a date field yields a java.sql.Date
object. Table 2-1 lists the different getXXX( )
methods, along with the corresponding SQL data type and Java data type. Where the return type for a getXXX( )
method is different from the Java type, the return type is shown in parentheses. Note that thejava.sql.Types
class defines integer constants that represent the standard SQL data types. Table 2-1: SQL Data Types, Java Types, and Default getXXX( ) Methods | ||
SQL Data Type | Java Type | getXXX( ) Method |
VARCHAR,CHAR | String | getString( ) |
BIT | Boolean (boolean) | getBoolean( ) |
TINYINT | Integer (byte) | getByte( ) |
SMALLINT | Integer (short) | getShort( ) |
INTEGER | Integer (int) | getInt( ) |
BIGINT | Long (long) | getLong( ) |
REAL | Float (float) | getFloat( ) |
FLOAT | Double (double) | getDouble( ) |
DOUBLE | Double (double) | getDouble( ) |
Note that this table merely lists the default mappings according to the JDBC specification, and some drivers don't follow these mappings exactly. Also, a certain amount of casting is permitted. For instance, the getString( )
method returns a String
representation of just about any data type.
Notes on Accessing ResultSet
There are means to make scroll-able cursors allow free access of any row in the result set. By default, cursors scroll forward only and are read only. When creating a Statement for a Connection, you can change the type of ResultSet to a more flexible scrolling or updatable model:
Table 2-2: JDBC 2.0 Record Scrolling Functions | |
Method | Function |
first( ) | Move to the first record. |
last( ) | Move to the last record. |
next( ) | Move to the next record. |
previous( ) | Move to the previous record. |
ResultSet rs = stmt.executeQuery("SELECT * FROM Sells");
The different options for types are TYPE_FORWARD_ONLY, TYPE_SCROLL_INSENSITIVE, and TYPE_SCROLL_SENSITIVE. You can choose whether the cursor is read-only or updatable using the options CONCUR_READ_ONLY, and CONCUR_UPDATABLE. With the default cursor, you can scroll forward using rs.next(). With scroll-able cursors you have more options:
rs.previous(); // moves back one tuple (tuple 2)
Transactions
JDBC allows SQL statements to be grouped together into a single transaction. Thus, we can ensure the ACID (Atomicity, Consistency, Isolation, Durability) properties using JDBC transactional features.
Transaction control is performed by the Connection object. When a connection is created, by default it is in the auto-commit mode. This means that each individual SQL statement is treated as a transaction by itself, and will be committed as soon as it's execution finished. We can turn off auto-commit mode for an active connection with :
con.setAutoCommit(false) ;
and turn it on again with :
con.setAutoCommit(true) ;
Once auto-commit is off, no SQL statements will be committed (that is, the database will not be permanently updated) until you have explicitly told it to commit by invoking the commit() method:
con.commit() ;
At any point before commit, we may invoke rollback() to rollback the transaction, and restore values to the last commit point (before the attempted updates).
Here is an example which ties these ideas together:
con.setAutoCommit(false);
Statement stmt = con.createStatement();
stmt.executeUpdate("INSERT INTO Sells VALUES('Bar Of Foo', 'BudLite', 1.00)" );
con.rollback();
stmt.executeUpdate("INSERT INTO Sells VALUES('Bar Of Joe', 'Miller', 2.00)" );
con.commit();
con.setAutoCommit(true);
Lets walk through the example to understand the effects of various methods. We first set auto-commit off, indicating that the following statements need to be considered as a unit. We attempt to insert into the Sells table the ('Bar Of Foo', 'BudLite', 1.00) tuple. However, this change has not been made final (committed) yet. When we invoke rollback, we cancel our insert and in effect we remove any intention of inserting the above tuple. Note that Sells now is still as it was before we attempted the insert. We then attempt another insert, and this time, we commit the transaction. It is only now that Sells is now permanently affected and has the new tuple in it. Finally, we reset the connection to auto-commit again.
0 comments:
Post a Comment