Aerospike Through SQL
Tags: aerospike, sql, api, crud
Published: 2022-08-16 15:57:30

<div className="text--center">

![(Source: Photo by Alex wong on Unsplash [Unsplash](https://unsplash.com/) )](https://cdn-images-1.medium.com/max/1600/0*8f9iXuwsmmzqrG_F)
_Source: Photo by Alex wong on Unsplash [Unsplash](https://unsplash.com/)_

</div>

SQL is broadly used as a data access language for analytics. Even if you are an application developer, chances are you have used it or at least are familiar with it. 

Aerospike has broad support for SQL, enabling you to use SQL to access Aerospike data in multiple ways.

<!--truncate-->
## Trino
For analytics, you can access Aerospike data on [Trino](https://trino.io/) with the Aerospike Trino Connector.

Through Trino, analytics use cases such as ad-hoc SQL queries, reports, and dashboard have access to data in one or more Aerospike clusters, and they can also merge Aerospike data with data from other sources.

For more details of the Trino Connector, see the blog posts [Deploy Aerospike and Trino based analytics platform using Docker](https://developer.aerospike.com/blog/deploy-aerospike-and-trino-based-analytics-platform-using-docker) and [Aerospike Trino Connector - Chapter Two](https://developer.aerospike.com/blog/aerospike-trino-connector-chapter-two).
 
Starburst is a SQL-based MPP query engine based on Trino that enables you to run Trino on a single machine, a cluster of machines, on-prem or in the cloud. The blog post [Analyze Data with Aerospike and Starburst Anywhere](https://developer.aerospike.com/blog/analyze-data-with-aerospike-and-starburst-anywhere) describes how to use [Starburst Enterprise](https://www.starburst.io). 

The data browser described in the blog post [Aerospike Data Browser](https://developer.aerospike.com/blog/aerospike-data-browser) uses Trino with the Trino Connector underneath. 

## Spark 
You can use Spark SQL to manipulate Aerospike data on the Spark platform. Aerospike Spark Connector provides parallel access to the Aerospike cluster from Spark.

Spark SQL merges two abstractions: Replicated Distributed Datasets (RDDs) and relational tables. Find examples of importing and storing Aerospike data to and from RDDs in [these Aerospike Spark tutorials](https://developer.aerospike.com/tutorials/spark). You can use Spark SQL to manipulate and process data in RDDs.

More details on the Spark Connector are available in the blog posts [Using Aerospike Connect for Spark](https://medium.com/aerospike-developer-blog/aerospike-is-a-highly-scalable-key-value-database-offering-best-in-class-performance-5922450aaa78) and [Accelerate Spark queries with Predicate Pushdown using Aerospike](https://developer.aerospike.com/blog/accelerate-spark-queries-with-predicate-pushdown-using-aerospike).

## JDBC 
Application developers can use simple SQL with JDBC with the community-contributed JDBC Connector. 
Please read more details in the blog post [Introducing Aerospike JDBC Driver](https://developer.aerospike.com/blog/introducing-aerospike-jdbc-driver).


**<div align="center">. . . .</div>**

## Aerospike API
While the various connectors allow broad SQL access for multiple purposes, the connectors may not be suitable for general applications as they do not provide the full Aerospike API functionality that a general application needs. For example, update capabilities are limited through SQL. 

We recommend that you use the Aerospike API  to access its full functionality and performance. Aerospike, a NoSQL database, does not directly support all SQL features. Inversely, Aerospike has many capabilities that cannot be expressed in SQL. This is to be expected because SQL is designed to provide physical data independence, which means the user need not worry about the physical details of the data such as the data distribution, size, selectivity, indexes, and so forth. The query optimizer deals with these details and selects the best execution plan. The goal of the Aerospike API is to  provide full control to developers for optimal performance of their applications.

**<div align="center">. . . .</div>**

This article describes how a developer who is familiar with SQL can quickly implement specific SQL CRUD operations using the Aerospike API. The goal is not to discuss the many mechanisms to control optimal performance (although it points to some of them), but to provide a ramp for a developer who has some knowledge of SQL to map the basic CRUD queries into the Aerospike API. We encourage you to learn about the performance features using the pointers provided.

While Aerospike supports many languages, we have used the Java client API in our examples as it is most widely used. The functionality is similar across all client libraries, and you can find equivalent functions in each.

**<div align="center">. . . .</div>**

## Mapping SQL to Aerospike
While there is no direct mapping of full SQL to Aerospike API, simple CRUD functionality can be easily mapped to Aerospike API as the underlying data models are similar: Aerospike’s set-record-bin organization matches the SQL’s table-record-column organization (see below). 

We point out differences and unsupported constructs below. They need to be handled through alternative means such as specific features, libraries, and application code.

### Similarities
Aerospike has a record-based data model. An Aerospike Database holds multiple namespaces, which are equivalent to databases in the relational model. A namespace holds records (rows), organized in sets (tables) that are accessed using a unique key that serves as the record ID. A record can contain one or more bins (columns), and a bin can hold a value of different data types. Sets and records do not conform to any schema. The primary index provides fast access to a record by key, which is a unique record identifier, while  secondary indexes defined on a bin are supported for content based access.

| SQL concept      | Aerospike equivalent |
| ---------| ----------- |
|Database or schema|Namespace
|Table|Set
|Record|Record
|Column|Bin
|Index|Primary and Secondary indexes
|Stored Procedures|User Defined Functions (UDFs)


### Differences
Aerospike is a NoSQL database, and its API has many differences from SQL databases.  Following are some key differences: 
- Set: A set is a tag on the record that gets created when the first record is created in the set. A set is schemaless and can hold records holding different bins. 
- Record: A record is schemaless, and can hold any combination of bins.
- Bin: A bin is typeless, and can hold a value of any type.  
- Index: Integrity constraints, such as uniqueness, cannot be specified on an index. 
- Transactions: All single record requests are transactional. The transaction boundary does not span multiple records. For a detailed discussion, see the blog post [Developers: Understanding Aerospike Transactions](https://aerospike.com/blog/developers-understanding-aerospike-transactions/).

### Constructs Not Directly Supported
Due to the differences in its data and execution models, Aerospike API does not directly support  the following SQL constructs, however, they can be implemented using data modeling, alternative features, and application code. We will discuss them later.
- Join
- Aggregations (max, min, top, average, sum, etc.)
- Order By, Distinct, Union
- Limit
- Constraints: NULL, Foreign Key, Default
- Built-in functions
- View


**<div align="center">. . . .</div>**

## Overview
For the purpose of our discussion, SQL queries can be organized in these categories: 
- SELECT or read operations, 
- CREATE, UPDATE, DELETE or write operations,
- Metadata operations, and
- Other functionality.

Use these [interactive tutorials](https://developer.aerospike.com/tutorials/java/sql-operations) to work along with this text.  

### A Word on Key, Metadata, Policy, and API Variants
Before we dive in, it is useful to know record key, record metadata, operation policy, and API variants. 
#### Record Key
Each record is uniquely identified by a key or id, consisting of a triple: (namespace, set, user-key) where user-key is a user-specified id that is unique within the set. The key (also called the digest) is returned in all read APIs.

#### Record Metadata
Each record has metadata associated with it: generation (or version) and expiration time (or time-to-live in seconds). This metadata is returned in all read operations. It is possible to retrieve only the metadata without the record's bins through the "getHeader" operation explained below.

#### Policy
Aerospike API calls take a policy parameter which includes many details of the how and what of the request. For example, timeout, retries, filter expression, and additional write semantics are specified in the policy object. We will specify  significant policy info that is relevant to the operation semantics in each operation below.

#### API Variants
Aerospike API is designed for control and simplicity. As such, a read and write operation that has one form in SQL has multiple variations in Aerospike API:
- By number of records involved: Single record, batch, and query
- By the processing mode: Sync, async, and background

In the following examples, only the synchronous APIs are shown when available, but you can easily discover the asynchronous variants in the documentation. 

## SQL SELECT and Equivalent Read Operations
### Single-Record Read Operations
#### Get

SQL Query|Equivalent Aerospike API (Java)
-----------|------------------------------
`SELECT * FROM namespace.set WHERE id = key`|`Record Client::get(Policy policy, Key key)`
`SELECT bins FROM namespace.set WHERE id = key`|`Record Client::get(Policy policy, Key key, String... binNames)`


#### Existence​
There is a variant of single record retrieval to check a record's existence.

SQL | Aerospike
-|- 
`SELECT EXISTS(SELECT * FROM namespace.set WHERE id = key)`|`boolean Client::exists(Policy policy, Key key)`


#### Metadata​
It is possible to only obtain a record's header info or metadata, consisting of generation (or version) and expiration (time-to-live in seconds).

SQL | Aerospike
-|- 
`SELECT generation, expiration FROM namespace.set WHERE id = key`|`Record Client::getHeader(Policy policy, Key key)`

### Batch Read Operations
A batch request operates on a list of records identified by the keys provided. It  works similar to a single record retrieval, except multiple records are returned. 

Batch requests are critical for high performance applications as they eliminate multiple client-server round trips, one for each record.

#### Read
SQL | Aerospike
-|- 
`SELECT * FROM namespace.set WHERE id IN key-list` | `Record[] Client::get(BatchPolicy policy, Key[] keys)`
`SELECT bins FROM namespace.set WHERE id in key-list` | `Record[] Client::get(BatchPolicy policy, Key[] keys, String... binNames)`

#### Existence​
There is a variant of batch retrieval to check record existence.

SQL | Aerospike
-|- 
`SELECT id, EXISTS(SELECT * FROM namespace.set WHERE id = key) WHERE key IN key-list` | `boolean[] Client::exists(Policy policy, Key[] keys)`

#### Metadata​
It is possible to obtain header info or metadata consisting of generation (or version) and expiration time (time-to-live in seconds) for a specified set of records.

SQL | Aerospike
-|-
`SELECT generation, expiration FROM namespace.set WHERE id IN key-list` | `Record[] Client::getHeader(Policy policy, Key[] keys)`

#### Composite Batch Read
A more general form of batch reads is also available that provides a union of simple batch results with different namespace, set, and bin specification. The `records` argument takes the input record keys and populates record details on return.

SQL | Aerospike
-|-
`(SELECT bins1 FROM namespace1.set1 WHERE id IN key-list1)` <br/> `UNION` <br/> `(SELECT bins2  FROM namespace2.set2  WHERE id IN key-list2)` <br/> `UNION ...` | `void Client::get(BatchPolicy policy, List<BatchRead> records)`

## Predicate-Based Read Operations
In predicate-based read operations (aka queries), records matching a general predicate or condition are retrieved. In SQL, the predicate is specified in the WHERE clause.
Aerospike provides two ways of performing an SQL query:
1. Using a secondary index based predicate, which can optionally be ANDed with an expression filter
2. Using a scan (which uses the primary “key” index), which can optionally be ANDed with an expression filter

### Secondary Index Query​
While a query in SQL doesn’t require an index to exist, the query API in Aerospike requires that the corresponding secondary index exists. 

The namespace, set, and secondary index based predicate is specified in the `statement` argument. The expression filter is optionally specified in the `policy` argument for additional conditions to be ANDed.

SQL | Aerospike
-|-
`SELECT bins FROM namespace.set WHERE condition` | `Record[] Client::query(QueryPolicy policy, Statement statement)`

### Scan 
The scan operation takes a `callback` object which is called for every record in the result (within the scope of the call which remains blocked until the operation completes).
The expression filter is optionally specified in the `policy` argument.

SQL | Aerospike
-|-
`SELECT bins FROM namespace.set WHERE condition`| `void Client::scanAll(ScanPolicy policy, String namespace, String setName, ScanCallback callback, String... binNames)`

## SQL CREATE, UPDATE, DELETE and Equivalent Write Operations
Aerospike combines Create and Update in a single write operation. The following `record-exists-action` options specified in the write-policy define the operation semantics if the record already exists:
- create-only: Create if record doesn't exist, fail otherwise.
- update: Create if record doesn't exist, update otherwise.
- update-only: Update if record exists, fail otherwise.
- replace: Create if record doesn't exist, replace otherwise.
- replace-only: Replace if record exists, fail otherwise.

SQL INSERT maps to `create-only` and SQL UPDATE maps to `update-only` options. SQL does not have a way to specify other options, such as replace, which removes an existing record.

### Single-Record Write Operations
#### INSERT and UPDATE
The put operation handles Create (Insert) and Update. 

SQL | Aerospike
-|-
`INSERT INTO namespace.set VALUES (id=key, bin=value, ...)` <br/> + <br/> `UPDATE namespace.set SET (bin=value, ...) WHERE id=key` | `void Client::put(WritePolicy policy, Key key, Bin... bins)`

#### Type-Specific Write Operations
Aerospike allows type-specific update operations. For integer and string types, they include the following. The `bins` argument holds multiple bin objects, each with the bin name and the operand value.  

SQL | Aerospike
-|-
`UPDATE namespace.set SET (bin = bin + intval) WHERE id=key` | `void Client::add(WritePolicy policy, Key key, Bin... bins)` 
`UPDATE namespace.set SET (bin = bin + strval) WHERE id=key` | `void Client::append(WritePolicy policy, Key key, Bin... bins) `
`UPDATE namespace.set SET (bin = strval + bin) WHERE id=key`| `void Client::prepend( WritePolicy policy, Key key, Bin... bins)`

Other type specific operations including on Collection Data Types (CDTs), are described in the [documentation](https://docs.aerospike.com/server/guide/data-types/cdt) and [tutorials](https://developer.aerospike.com/tutorials/java/cdt).

#### DELETE 
SQL | Aerospike
-|-
`DELETE FROM namespace.set WHERE id=key`| `void Client::delete(WritePolicy policy, Key key)`

### Batch Write Operations
A batch write operates on multiple records specifically identified with a list of keys. There is a batch API for insertion, update, and deletion of multiple records.
Two forms of batch writes are shown below. Other forms including one with a UDF (described below) and key-specific operations are described in the blog post [Batch Operations](https://developer.aerospike.com/blog/batch-operations-in-aerospike). 

The argument ops is a list of operations to be performed in the specified sequence on each record, and can include read as well as write operations. The argument `batchPolicy` contains the specifics of  how the batch is processed, whereas the arguments `writePolicy` and `deletePolicy` have the specifics of how the respective individual record operation is performed.

SQL | Aerospike
-|-
`UPDATE namespace.set SET (bin1=fn_1(bin_1), ...) WHERE id in key-list` <br/> + <br/> `SELECT fn_n(bin_n), ,,, FROM namespace.set WHERE id in key-list` | `BatchResults operate(BatchPolicy batchPolicy, BatchWritePolicy writePolicy Key[] keys, Operation... ops)`
`DELETE FROM namespace.set WHERE id in key-list`| `BatchResults delete BatchPolicy batchPolicy, BatchDeletePolicy deletePolicy, Key[] keys)`

### Predicate-Based Write Operations

Predicate-based updates and deletes are possible by specifying the WHERE condition using the secondary index predicate (specified in a statement object) and expression filter (specified in the write policy) as explained earlier.

Predicate-based updates and deletes can involve a large number of records, and therefore are processed in background execution mode with the execute API. Sync and callback async modes are not available. Two forms of execute are possible:

1. Using a list of bin updates and deletes: A multi-op request provides a list of bin operations. Multi-op requests are further described below.


 Since `execute` performs in a background mode with no returned results, the operation list in the `statement` object cannot have a read operation, only updates.

SQL | Aerospike
-|-
`UPDATE namespace.set SET (bin=value, ...) WHERE condition ` <br/> + <br/> `DELETE FROM namespace.set WHERE condition` |  `Client::execute(WritePolicy policy, Statement statement)`

2. User Defined Functions (UDFs): UDFs are equivalent to stored procedures, and are described further below. Record-oriented UDFs implement arbitrary logic in a Lua function that is registered with the server and invoked through an API call. 

SQL | Aerospike
-|-
`UPDATE namespace.set SET (bin1=fn1(args), ...) WHERE condition` <br/> + <br/> `DELETE FROM namespace.set WHERE condition` | `ExecuteTask Client::execute(WritePolicy policy, Statement statement, String packageName, String functionName, Value... functionArgs)`

## SQL Stored Procedures and Aerospike User Defined Functions (UDFs)
User Defined Functions (UDFs) are equivalent to stored procedures in SQL systems. A custom User Defined Function (UDF) is written in Lua, registered on the server, and invoked for a specified record(s). You can find further details in the documentation on [User Defined Functions (UDFs)](https://docs.aerospike.com/server/architecture/udf). 

In the following example, the UDF is specified using the arguments `packageName` and `functionName`, and supplied a list of arguments it expects in `functionArgs`. The API returns a generic `Object` which can be anything like a single value or a map of key-value pairs. 

SQL | Aerospike
-|-
`EXEC StoredProcedure @arg1 = val1, @arg2 = val2, …` | `Object Client::execute(WritePolicy policy, Key key, String packageName, String functionName, Value... functionArgs)`

A UDF can have arbitrary logic combining CRUD operations. 

In Aerospike, aggregation functions such as MIN, MAX, AVERAGE, SUM, etc, over multiple records are implemented with Stream UDFs. This article does not cover the specifics of Stream UDFs; please refer to the tutorials on [SQL: Aggregates](https://developer.aerospike.com/tutorials/java/sql_aggregates_1). 

## Multi-Op Requests
Multiple single bin read and write operations are possible through the `operate` API. It differs from the dedicated “single-op” requests, which allow just one operation. The operations in the argument `operations` are executed atomically and in the order specified, 

Unlike in SQL, read and write operations can be combined in the same request (for single-record and batch requests) as illustrated below.

SQL | Aerospike
-|-
`SELECT fn1(bin1), …FROM namespace.set WHERE id=key` <br/> + <br/> `UPDATE namespace.set SET (bin1=fn_n(bin_n), ...) WHERE id=key` | `Record Client::operate( WritePolicy policy, Key key, Operation... operations)`

Multi-op operate APIs are available for a single record, batch, and query operations. See [this tutorial](https://developer.aerospike.com/tutorials/java/java-intro_to_transactions) that illustrates multi-ops.


## Metadata Operations
### Namespace Operations
#### CREATE Namespace​
There is no API to create a namespace. A namespace is added through the config and requires a server restart.
#### TRUNCATE Namespace​
The truncate API removes all records in a set or the entire namespace. 

SQL | Aerospike
-|-
`TRUNCATE namespace` | `void Client::truncate(policy, namespace, set=null, beforeLastUpdate=null)`

#### DELETE Namespace​
There is no API to delete a namespace. A namespace has one or more dedicated storage devices, and they must be wiped clean to delete the namespace. 

### Set Operations
#### CREATE Set​
There is no explicit operation to create a set. A set is created when the first record is inserted in the set.
#### ALTER Set​
A set is schemaless, and can hold records that have different schemas or bins. A bin has no type associated with it, and can hold values of any type. Therefore ALTER operation on a set to modify its schema is not needed.
#### TRUNCATE Set​
All records in a set can be truncated using the truncate API:

SQL | Aerospike
-|-
`TRUNCATE namespace.set` | `void Client::truncate(policy, namespace, set, beforeLastUpdate=null)`

#### DROP Set​
There is no notion of deleting a set as a set is just a name that a record is tagged with. The namespace must be deleted to remove the set name.
### Index Operations
#### CREATE Index​
An index is created on a bin for a specific value type. Integer, string, and GeoJSON types are currently supported for indexing. 

SQL | Aerospike
-|-
`CREATE Index` | `createIndex(Policy policy, String namespace, String setName, String indexName, String binName,IndexType indexType)`

#### DROP Index​
SQL | Aerospike
-|-
`DROP Index` | `dropIndex(Policy policy, String namespace, String setName, String indexName)`

### UDF Operations
#### CREATE UDF
The arguments `clientPath` and `serverPath` below define the path to UDFfile  on the client and server respectively.

SQL | Aerospike
-|-
`CREATE StoredProcedure` | `Client::register(Policy policy, String clientPath, String serverPath, Language.LUA)`

## Other SQL Capabilities
### Join
Most NoSQL databases do not have the Join operation as it is slow and complex.  You can avoid Joins by storing the joined objects in aggregate form. Alternatively, the join can be performed in the application by retrieving the referenced object.
### Limit
The policy parameter `max-records` can be specified as a hint. Fewer objects may be returned as the limit gets divided among participating nodes.
### Order By, Top, Union, Distinct
List and Expressions can be used to implement these operations. Alternatively, they can be performed in the application. 
### Aggregations 
Aggregations involving Group-By, Having, and Aggregate Functions (such as Max, Min, Top, Average, Sum) can be implemented using Stream UDFs as shown in the tutorials [SQL Aggregates - Part 1](https://developer.aerospike.com/tutorials/java/sql_aggregates_1) and [Part 2](https://developer.aerospike.com/tutorials/java/sql_aggregates_2).
### Constraints
Integrity constraints such as NULL, Foreign Key, Default should be handled in the application logic. The uniqueness constraint can be enforced in a List or Map.
### Built-In Functions
Many built-in functions like UPPER, TRIM, can be implemented with Expressions or UDFs.

## Going Beyond SQL with Aerospike
​​In order to get the most out of Aerospike for speed-at-scale, thinking beyond SQL is necessary. 
The process starts with modeling your data for performance, scale, and other needs of the application. Please review the series [Data Modeling for Speed At Scale](https://developer.aerospike.com/blog/data-modeling-for-speed-at-scale).

Learn about and use the various performance features that the Aerospike API provides through the [documentation](https://docs.aerospike.com/) and  [tutorials](https://developer.aerospike.com/tutorials/). Examples of such features include:
- Collection Data Types (CDTs) 
- Multi-op requests
- Batch requests
- Expressions
- Secondary indexes
- Set indexes
- Complex Data Types - Binary, HLL, GeoJSON
- User Defined Functions (UDFs)

## Summary
You can use SQL to access Aerospike data through the Trino, Spark, and JDBC Connectors. While the connectors work quite well for the environment and intent they are built for, they do not provide the full Aerospike API functionality that the application may need. Therefore, use of the Aerospike API is recommended for full functionality and performance. The Aerospike API is designed with the goal of enabling developers of high performance applications who need to control performance specific details to make better decisions. 

The article describes how a developer who is familiar with SQL can quickly implement specific SQL CRUD operations using the Aerospike API. Coming from a SQL background, it is important to remember that through NoSQL data modeling one should be able to avoid certain SQL features entirely such as the Join to maximize the benefit of using Aerospike for performance and scale. With the introduction provided in this article, you should be able to take the next step to learn the mechanisms in Aerospike API to optimize your application’s performance and scale. 

### Related Links:
- [SQL Operations](https://developer.aerospike.com/tutorials/java/sql-operations) (interactive tutorials)
- [Aerospike Documentation](https://docs.aerospike.com/) (documentation)
- [Aerospike Tutorials](https://developer.aerospike.com/tutorials/) (interactive tutorials)
- [Aerospike Developer Hub](https://developer.aerospike.com)
- [Developers: Understanding Aerospike Transactions](https://aerospike.com/blog/developers-understanding-aerospike-transactions/) (blog post)
- [Collection Data Types (CDTs)](https://docs.aerospike.com/server/guide/data-types/cdt) (documentation) 
- [Collection Data Types (CDTs)](https://developer.aerospike.com/tutorials/java/cdt) (interactive tutorials)
- [Batch Operations](https://developer.aerospike.com/blog/batch-operations-in-aerospike)  (blog post)
- [Introduction to Transactions](https://developer.aerospike.com/tutorials/java/java-intro_to_transactions) (interactive tutorial)
- [User Defined Functions (UDFs)](https://docs.aerospike.com/server/architecture/udf) (documentation)
- [Data Modeling for Speed At Scale](https://developer.aerospike.com/blog/data-modeling-for-speed-at-scale) (blog post)
- [Deploy Aerospike and Trino based analytics platform using Docker](https://developer.aerospike.com/blog/deploy-aerospike-and-trino-based-analytics-platform-using-docker) (blog post)
- [Aerospike Trino Connector - Chapter Two](https://developer.aerospike.com/blog/aerospike-trino-connector-chapter-two) (blog post)
- [Analyze Data with Aerospike and Starburst Anywhere](https://developer.aerospike.com/blog/analyze-data-with-aerospike-and-starburst-anywhere) (blog post)
- [Aerospike Data Browser](https://developer.aerospike.com/blog/aerospike-data-browser) (blog post)
- [Aerospike Spark Tutorials](https://developer.aerospike.com/tutorials/spark) (interactive tutorials)
- [Using Aerospike Connect for Spark](https://medium.com/aerospike-developer-blog/aerospike-is-a-highly-scalable-key-value-database-offering-best-in-class-performance-5922450aaa78) (blog post) 
- [Accelerate Spark queries with Predicate Pushdown using Aerospike](https://developer.aerospike.com/blog/accelerate-spark-queries-with-predicate-pushdown-using-aerospike) (blog post)
- [Introducing Aerospike JDBC Driver](https://developer.aerospike.com/blog/introducing-aerospike-jdbc-driver) (blog post)