Java Development with Microsoft SQL Server | by Gary A. Stafford | Towards Data Science

Enterprise software solutions often combine multiple technology platforms. Accessing an Oracle database via a Microsoft .NET application and vice-versa, accessing Microsoft SQL Server from a Java-based application is common. In this post, we will explore the use of the JDBC (Java Database Connectivity) API to call stored procedures from a Microsoft SQL Server 2017 database and return data to a Java 11-based console application.
The objectives of this post include:
Demonstrate the differences between using static SQL statements and stored procedures to return data.
Demonstrate three types of JDBC statements to return data: Statement, PreparedStatement, and CallableStatement.
Demonstrate how to call stored procedures with input and output parameters.
Demonstrate how to return single values and a result set from a database using stored procedures.
To access data, many enterprise software organizations require their developers to call stored procedures within their code as opposed to executing static T-SQL (Transact-SQL) statements against the database. There are several reasons stored procedures are preferred:
Optimization: Stored procedures are often written by database administrators (DBAs) or database developers who specialize in database development. They understand the best way to construct queries for optimal performance and minimal load on the database server. Think of it as a developer using an API to interact with the database.
Safety and Security: Stored procedures are considered safer and more secure than static SQL statements. The stored procedure provides tight control over the content of the queries, preventing malicious or unintentionally destructive code from being executed against the database.
Error Handling: Stored procedures can contain logic for handling errors before they bubble up to the application layer and possibly to the end-user.
For brevity, I will use an existing and well-known Microsoft SQL Server database, AdventureWorks. The AdventureWorks database was originally published by Microsoft for SQL Server 2008. Although a bit dated architecturally, the database comes prepopulated with plenty of data for demonstration purposes.
For the demonstration, I have created an Amazon RDS for SQL Server 2017 Express Edition instance on AWS. You have several options for deploying SQL Server, including AWS, Microsoft Azure, Google Cloud, or installed on your local workstation.
There are many methods to deploy the AdventureWorks database to Microsoft SQL Server. For this post’s demonstration, I used the AdventureWorks2017.bak backup file, which I copied to Amazon S3. I then enabled and configured the native backup and restore feature of Amazon RDS for SQL Server to import and install the backup.
DROP DATABASE IF EXISTS AdventureWorks;GOEXECUTE msdb.dbo.rds_restore_database     @restore_db_name='AdventureWorks',     @s3_arn_to_restore_from='arn:aws:s3:::my-bucket/AdventureWorks2017.bak',     @type='FULL',     @with_norecovery=0;-- get task_id from output (e.g. 1)EXECUTE msdb.dbo.rds_task_status     @db_name='AdventureWorks',     @task_id=1;
For the demonstration, I have added four stored procedures to the AdventureWorks database to use in this post. To follow along, you will need to install these stored procedures, which are included in the GitHub project.
Using the latest Microsoft JDBC Driver 8.4 for SQL Server (version 8.4.1.jre11), we create a SQL Server data source, com.microsoft.sqlserver.jdbc.SQLServerDataSource, and database connection, java.sql.Connection. There are several patterns for creating and working with JDBC data sources and connections. This post does not necessarily focus on the best practices for creating or using either. In this example, the application instantiates a connection class, SqlConnection.java, which in turn instantiates the java.sql.Connection and com.microsoft.sqlserver.jdbc.SQLServerDataSource objects. The data source’s properties are supplied from an instance of a singleton class, ProjectProperties.java. This class instantiates the java.util.Properties class, which reads values from a configuration properties file, config.properties. On startup, the application creates the database connection, calls each of the example methods, and then closes the connection.
For each example, I will show the stored procedure, if applicable, followed by the Java method that calls the procedure or executes the static SQL statement. For brevity, I have left out the data source and connection code in the article. Again, a complete copy of all the code for this article is available on GitHub, including Java source code, SQL statements, helper SQL scripts, and a set of basic JUnit tests. Use the following command to git clone a local copy of the project.
git clone --branch master --single-branch --depth 1 --no-tags \    https://github.com/garystafford/mssql-sp-java-jdbc-2020.git
To run the JUnit unit tests, using Gradle, which the project is based on, use the ./gradlew cleanTest test --warning-mode none command.
To build and run the application, using Gradle, which the project is based on, use the ./gradlew run --warning-mode none command.
Before jumping into stored procedures, we will start with a simple static SQL statement. This example’s method, getAverageProductWeightST, uses the java.sql.Statement class. According to Oracle’s JDBC documentation, the Statement object is used for executing a static SQL statement and returning the results it produces. This SQL statement calculates the average weight of all products in the AdventureWorks database. It returns a solitary double numeric value. This example demonstrates one of the simplest methods for returning data from SQL Server.
/** * Statement example, no parameters, returns Integer * * @return Average weight of all products */public double getAverageProductWeightST() {    double averageWeight = 0;    Statement stmt = null;    ResultSet rs = null;try {        stmt = connection.getConnection().createStatement();        String sql = "WITH Weights_CTE(AverageWeight) AS" +                "(" +                "    SELECT [Weight] AS [AverageWeight]" +                "    FROM [Production].[Product]" +                "    WHERE [Weight] > 0" +                "        AND [WeightUnitMeasureCode] = 'LB'" +                "    UNION" +                "    SELECT [Weight] * 0.00220462262185 AS [AverageWeight]" +                "    FROM [Production].[Product]" +                "    WHERE [Weight] > 0" +                "        AND [WeightUnitMeasureCode] = 'G')" +                "SELECT ROUND(AVG([AverageWeight]), 2)" +                "FROM [Weights_CTE];";        rs = stmt.executeQuery(sql);        if (rs.next()) {            averageWeight = rs.getDouble(1);        }    } catch (Exception ex) {        Logger.getLogger(RunExamples.class.getName()).                log(Level.SEVERE, null, ex);    } finally {        if (rs != null) {            try {                rs.close();            } catch (SQLException ex) {                Logger.getLogger(RunExamples.class.getName()).                        log(Level.WARNING, null, ex);            }        }        if (stmt != null) {            try {                stmt.close();            } catch (SQLException ex) {                Logger.getLogger(RunExamples.class.getName()).                        log(Level.WARNING, null, ex);            }        }    }    return averageWeight;}
Next, we will execute almost the same static SQL statement as in Example 1. The only change is the addition of the column name, averageWeight. This allows us to parse the results by column name, making the code easier to understand as opposed to using the numeric index of the column as in Example 1.
Also, instead of using the java.sql.Statement class, we use the java.sql.PreparedStatement class. According to Oracle’s documentation, a SQL statement is precompiled and stored in a PreparedStatement object. This object can then be used to execute this statement multiple times efficiently.
/** * PreparedStatement example, no parameters, returns Integer * * @return Average weight of all products */public double getAverageProductWeightPS() {    double averageWeight = 0;    PreparedStatement pstmt = null;    ResultSet rs = null;try {        String sql = "WITH Weights_CTE(averageWeight) AS" +                "(" +                "    SELECT [Weight] AS [AverageWeight]" +                "    FROM [Production].[Product]" +                "    WHERE [Weight] > 0" +                "        AND [WeightUnitMeasureCode] = 'LB'" +                "    UNION" +                "    SELECT [Weight] * 0.00220462262185 AS [AverageWeight]" +                "    FROM [Production].[Product]" +                "    WHERE [Weight] > 0" +                "        AND [WeightUnitMeasureCode] = 'G')" +                "SELECT ROUND(AVG([AverageWeight]), 2) AS [averageWeight]" +                "FROM [Weights_CTE];";        pstmt = connection.getConnection().prepareStatement(sql);        rs = pstmt.executeQuery();        if (rs.next()) {            averageWeight = rs.getDouble("averageWeight");        }    } catch (Exception ex) {        Logger.getLogger(RunExamples.class.getName()).                log(Level.SEVERE, null, ex);    } finally {        if (rs != null) {            try {                rs.close();            } catch (SQLException ex) {                Logger.getLogger(RunExamples.class.getName()).                        log(Level.WARNING, null, ex);            }        }        if (pstmt != null) {            try {                pstmt.close();            } catch (SQLException ex) {                Logger.getLogger(RunExamples.class.getName()).                        log(Level.WARNING, null, ex);            }        }    }    return averageWeight;}
In this example, the average product weight query has been moved into a stored procedure. The procedure is identical in functionality to the static statement in the first two examples. To call the stored procedure, we use the java.sql.CallableStatement class. According to Oracle’s documentation, the CallableStatement extends PreparedStatement. It is the interface used to execute SQL stored procedures. The CallableStatement accepts both input and output parameters; however, this simple example does not use either. Like the previous two examples, the procedure returns a double numeric value.
CREATE ORALTER PROCEDURE [Production].[uspGetAverageProductWeight]ASBEGIN  SET NOCOUNT ON;WITH    Weights_CTE(AverageWeight)    AS    (        SELECT [Weight] AS [AverageWeight]        FROM [Production].[Product]        WHERE [Weight] > 0          AND [WeightUnitMeasureCode] = 'LB'      UNION        SELECT [Weight] * 0.00220462262185 AS [AverageWeight]        FROM [Production].[Product]        WHERE [Weight] > 0          AND [WeightUnitMeasureCode] = 'G'    )  SELECT ROUND(AVG([AverageWeight]), 2)  FROM [Weights_CTE];ENDGO
The calling Java method is shown below.
/** * CallableStatement, no parameters, returns Integer * * @return Average weight of all products */public double getAverageProductWeightCS() {    CallableStatement cstmt = null;    double averageWeight = 0;    ResultSet rs = null;    try {        cstmt = connection.getConnection().prepareCall(                "{call [Production].[uspGetAverageProductWeight]}");        cstmt.execute();        rs = cstmt.getResultSet();        if (rs.next()) {            averageWeight = rs.getDouble(1);        }    } catch (Exception ex) {        Logger.getLogger(RunExamples.class.getName()).                log(Level.SEVERE, null, ex);    } finally {        if (rs != null) {            try {                rs.close();            } catch (SQLException ex) {                Logger.getLogger(RunExamples.class.getName()).                        log(Level.SEVERE, null, ex);            }        }        if (cstmt != null) {            try {                cstmt.close();            } catch (SQLException ex) {                Logger.getLogger(RunExamples.class.getName()).                        log(Level.WARNING, null, ex);            }        }    }    return averageWeight;}
In this example, we use almost the same stored procedure as in Example 3. The only difference is the inclusion of an output parameter. This time, instead of returning a result set with a value in a single unnamed column, the column has a name, averageWeight. We can now call that column by name when retrieving the value.
The stored procedure patterns found in Examples 3 and 4 are both commonly used. One procedure uses an output parameter, and one not, both return the same values. You can use the CallableStatement to for either type.
CREATE ORALTER PROCEDURE [Production].[uspGetAverageProductWeightOUT]  @averageWeight DECIMAL(8, 2) OUTASBEGIN  SET NOCOUNT ON;WITH    Weights_CTE(AverageWeight)    AS    (        SELECT [Weight] AS [AverageWeight]        FROM [Production].[Product]        WHERE [Weight] > 0          AND [WeightUnitMeasureCode] = 'LB'      UNION        SELECT [Weight] * 0.00220462262185 AS [AverageWeight]        FROM [Production].[Product]        WHERE [Weight] > 0          AND [WeightUnitMeasureCode] = 'G'    )  SELECT @averageWeight = ROUND(AVG([AverageWeight]), 2)  FROM [Weights_CTE];ENDGO
The calling Java method is shown below.
/** * CallableStatement example, (1) output parameter, returns Integer * * @return Average weight of all products */public double getAverageProductWeightOutCS() {    CallableStatement cstmt = null;    double averageWeight = 0;try {        cstmt = connection.getConnection().prepareCall(                "{call [Production].[uspGetAverageProductWeightOUT](?)}");        cstmt.registerOutParameter("averageWeight", Types.DECIMAL);        cstmt.execute();        averageWeight = cstmt.getDouble("averageWeight");    } catch (Exception ex) {        Logger.getLogger(RunExamples.class.getName()).                log(Level.SEVERE, null, ex);    } finally {        if (cstmt != null) {            try {                cstmt.close();            } catch (SQLException ex) {                Logger.getLogger(RunExamples.class.getName()).                        log(Level.WARNING, null, ex);            }        }    }    return averageWeight;}
In this example, the procedure returns a result set of type java.sql.ResultSet, of employees whose last name starts with a particular sequence of characters (e.g., ‘M’ or ‘Sa’). The sequence of characters is passed as an input parameter, lastNameStartsWith, to the stored procedure using the CallableStatement.
The method making the call iterates through the rows of the result set returned by the stored procedure, concatenating multiple columns to form the employee’s full name as a string. Each full name string is then added to an ordered collection of strings, a List<String> object. The List instance is returned by the method. You will notice this procedure takes a little longer to run because of the use of the LIKE operator. The database server has to perform pattern matching on each last name value in the table to determine the result set.
CREATE ORALTER PROCEDURE [HumanResources].[uspGetEmployeesByLastName]  @lastNameStartsWith VARCHAR(20) = 'A'ASBEGIN  SET NOCOUNT ON;SELECT p.[FirstName], p.[MiddleName], p.[LastName], p.[Suffix], e.[JobTitle], m.[EmailAddress]  FROM [HumanResources].[Employee] AS e    LEFT JOIN [Person].[Person] p ON e.[BusinessEntityID] = p.[BusinessEntityID]    LEFT JOIN [Person].[EmailAddress] m ON e.[BusinessEntityID] = m.[BusinessEntityID]  WHERE e.[CurrentFlag] = 1    AND p.[PersonType] = 'EM'    AND p.[LastName] LIKE @lastNameStartsWith + '%'  ORDER BY p.[LastName], p.[FirstName], p.[MiddleName]ENDGO
The calling Java method is shown below.
/** * CallableStatement example, (1) input parameter, returns ResultSet * * @param lastNameStartsWith * @return List of employee names */public List<String> getEmployeesByLastNameCS(String lastNameStartsWith) {CallableStatement cstmt = null;    ResultSet rs = null;    List<String> employeeFullName = new ArrayList<>();try {        cstmt = connection.getConnection().prepareCall(                "{call [HumanResources].[uspGetEmployeesByLastName](?)}",                ResultSet.TYPE_SCROLL_INSENSITIVE,                ResultSet.CONCUR_READ_ONLY);cstmt.setString("lastNameStartsWith", lastNameStartsWith);        boolean results = cstmt.execute();        int rowsAffected = 0;// Protects against lack of SET NOCOUNT in stored procedure        while (results || rowsAffected != -1) {            if (results) {                rs = cstmt.getResultSet();                break;            } else {                rowsAffected = cstmt.getUpdateCount();            }            results = cstmt.getMoreResults();        }        while (rs.next()) {            employeeFullName.add(                    rs.getString("LastName") + ", "                            + rs.getString("FirstName") + " "                            + rs.getString("MiddleName"));        }    } catch (Exception ex) {        Logger.getLogger(RunExamples.class.getName()).                log(Level.SEVERE, null, ex);    } finally {        if (rs != null) {            try {                rs.close();            } catch (SQLException ex) {                Logger.getLogger(RunExamples.class.getName()).                        log(Level.WARNING, null, ex);            }        }        if (cstmt != null) {            try {                cstmt.close();            } catch (SQLException ex) {                Logger.getLogger(RunExamples.class.getName()).                        log(Level.WARNING, null, ex);            }        }    }    return employeeFullName;}
In this last example, we pass two input parameters, productColor and productSize, to a stored procedure. The stored procedure returns a result set containing several columns of product information. This time, the example’s method iterates through the result set returned by the procedure and constructs an ordered collection of products, List<Product> object. The Product objects in the list are instances of the Product.java POJO class. The method converts each results set’s row-level field value into a Product property (e.g., Product.Size, Product.Model). Using a collection is a common method for persisting data from a result set in an application.
CREATE ORALTER PROCEDURE [Production].[uspGetProductsByColorAndSize]  @productColor VARCHAR(20),  @productSize INTEGERASBEGIN  SET NOCOUNT ON;SELECT p.[ProductNumber], m.[Name] AS [Model], p.[Name] AS [Product], p.[Color], p.[Size]  FROM [Production].[ProductModel] AS m    INNER JOIN    [Production].[Product] AS p ON m.[ProductModelID] = p.[ProductModelID]  WHERE (p.[Color] = @productColor)    AND (p.[Size] = @productSize)  ORDER BY p.[ProductNumber], [Model], [Product]ENDGO
The calling Java method is shown below.
/** * CallableStatement example, (2) input parameters, returns ResultSet * * @param color * @param size * @return List of Product objects */public List<Product> getProductsByColorAndSizeCS(String color, String size) {CallableStatement cstmt = null;    ResultSet rs = null;    List<Product> productList = new ArrayList<>();try {        cstmt = connection.getConnection().prepareCall(                "{call [Production].[uspGetProductsByColorAndSize](?, ?)}",                ResultSet.TYPE_SCROLL_INSENSITIVE,                ResultSet.CONCUR_READ_ONLY);cstmt.setString("productColor", color);        cstmt.setString("productSize", size);        boolean results = cstmt.execute();        int rowsAffected = 0;// Protects against lack of SET NOCOUNT in stored procedure        while (results || rowsAffected != -1) {            if (results) {                rs = cstmt.getResultSet();                break;            } else {                rowsAffected = cstmt.getUpdateCount();            }            results = cstmt.getMoreResults();        }while (rs.next()) {            Product product = new Product(                    rs.getString("Product"),                    rs.getString("ProductNumber"),                    rs.getString("Color"),                    rs.getString("Size"),                    rs.getString("Model"));            productList.add(product);        }    } catch (Exception ex) {        Logger.getLogger(RunExamples.class.getName()).                log(Level.SEVERE, null, ex);    } finally {        if (rs != null) {            try {                rs.close();            } catch (SQLException ex) {                Logger.getLogger(RunExamples.class.getName()).                        log(Level.WARNING, null, ex);            }        }        if (cstmt != null) {            try {                cstmt.close();            } catch (SQLException ex) {                Logger.getLogger(RunExamples.class.getName()).                        log(Level.WARNING, null, ex);            }        }    }    return productList;}
You will notice in all T-SQL statements, I refer to the schema as well as the table or stored procedure name (e.g., {call [Production].[uspGetAverageProductWeightOUT](?)}). According to Microsoft, it is always good practice to refer to database objects by a schema name and the object name, separated by a period; that even includes the default schema (e.g., dbo).
You will also notice I wrap the schema and object names in square brackets (e.g., SELECT [ProductNumber] FROM [Production].[ProductModel]). The square brackets are to indicate that the name represents an object and not a reserved word (e.g, CURRENT or NATIONAL). By default, SQL Server adds these to make sure the scripts it generates run correctly.
The application will display the name of the method being called, a method description, the duration of time it took to retrieve the data, and any results returned by the method.
Below, we see the results.
This post is certainly not about SQL performance, demonstrated by the fact I am only using Amazon RDS for SQL Server 2017 Express Edition on a single, very underpowered db.t2.micro Amazon RDS instance types. However, I have added a timer feature, ProcessTimer.java class, to capture the duration of time each example takes to return data, measured in milliseconds. The ProcessTimer.java class is part of the project code. Using the timer, you should observe significant differences between the first run and proceeding runs of the application for several of the called methods. The time difference is a result of several factors, primarily pre-compilation of the SQL statements and SQL Server plan caching.
The effects of these two factors are easily demonstrated by clearing the SQL Server plan cache (see SQL script below) using DBCC (Database Console Commands) statements. and then running the application twice in a row. The second time, pre-compilation and plan caching should result in significantly faster times for the prepared statements and callable statements, in Examples 2–6. In the two random runs shown below, we see up to a 5x improvement in query time.
USE AdventureWorks;DBCC FREESYSTEMCACHE('SQL Plans');GOCHECKPOINT;GO-- Impossible to run with Amazon RDS for Microsoft SQL Server on AWS-- DBCC DROPCLEANBUFFERS;-- GO
The first run results are shown below.
SQL SERVER STATEMENT EXAMPLES======================================Method: GetAverageProductWeightSTDescription: Statement, no parameters, returns IntegerDuration (ms): 122Results: Average product weight (lb): 12.43---Method: GetAverageProductWeightPSDescription: PreparedStatement, no parameters, returns IntegerDuration (ms): 146Results: Average product weight (lb): 12.43---Method: GetAverageProductWeightCSDescription: CallableStatement, no parameters, returns IntegerDuration (ms): 72Results: Average product weight (lb): 12.43---Method: GetAverageProductWeightOutCSDescription: CallableStatement, (1) output parameter, returns IntegerDuration (ms): 623Results: Average product weight (lb): 12.43---Method: GetEmployeesByLastNameCSDescription: CallableStatement, (1) input parameter, returns ResultSetDuration (ms): 830Results: Last names starting with 'Sa': 7         Last employee found: Sandberg, Mikael Q---Method: GetProductsByColorAndSizeCSDescription: CallableStatement, (2) input parameter, returns ResultSetDuration (ms): 427Results: Products found (color: 'Red', size: '44'): 7         First product: Road-650 Red, 44 (BK-R50R-44)---
The second run results are shown below.
SQL SERVER STATEMENT EXAMPLES======================================Method: GetAverageProductWeightSTDescription: Statement, no parameters, returns IntegerDuration (ms): 116Results: Average product weight (lb): 12.43---Method: GetAverageProductWeightPSDescription: PreparedStatement, no parameters, returns IntegerDuration (ms): 89Results: Average product weight (lb): 12.43---Method: GetAverageProductWeightCSDescription: CallableStatement, no parameters, returns IntegerDuration (ms): 80Results: Average product weight (lb): 12.43---Method: GetAverageProductWeightOutCSDescription: CallableStatement, (1) output parameter, returns IntegerDuration (ms): 340Results: Average product weight (lb): 12.43---Method: GetEmployeesByLastNameCSDescription: CallableStatement, (1) input parameter, returns ResultSetDuration (ms): 139Results: Last names starting with 'Sa': 7         Last employee found: Sandberg, Mikael Q---Method: GetProductsByColorAndSizeCSDescription: CallableStatement, (2) input parameter, returns ResultSetDuration (ms): 208Results: Products found (color: 'Red', size: '44'): 7         First product: Road-650 Red, 44 (BK-R50R-44)---
This post has demonstrated several methods for querying and calling stored procedures from a SQL Server 2017 database using JDBC with the Microsoft JDBC Driver 8.4 for SQL Server. Although the examples are quite simple, the same patterns can be used with more complex stored procedures, with multiple input and output parameters, which not only select, but insert, update, and delete data.
There may be some limitations of the Microsoft JDBC Driver for SQL Server you should be aware of by reading the documentation. However, for most tasks that require database interaction, the Driver provides adequate functionality with SQL Server.
This blog represents my own viewpoints and not of my employer, Amazon Web Services.