Providing REST endpoints that JOIN table data

Question

I want to provide data from a DB-Connected REST endpoint that JOINs data from one or more other tables; how to I accomplish this?

Answer

zend-db provides SQL JOIN syntax via its Laminas\Db\Sql subcomponent. However, DB-Connected REST services use Laminas\Db\TableGateway, which does not provide this out-of-the-box. As such, we will need to make a few changes to our application to make this work.

For the purposes of this recipe, we have created the following database schema:

CREATE TABLE locations (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    city VARCHAR(255)
);

CREATE TABLE users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name VARCHAR(255),
    location_id INTEGER NOT NULL,
    FOREIGN KEY(location_id) REFERENCES locations(id)
);

We have also populated it with some sample data; we leave that detail to the user.

We have created a SQLite3 database in data/db/api.db using the above schema, and setup an adapter via the Admin UI named users_locations that points to it. We then created a new API, Users, and a single DB-Connected REST service, Users, pointing to the users table of this database. This has created the following tree:

modules/Users/
├── config
│   └── module.config.php
├── Module.php
├── src
│   └── V1
│       ├── Rest
│       │   └── Users
│       │       ├── UsersCollection.php
│       │       ├── UsersEntity.php

The goal

In our API, we want to provide individual users with the following fields:

  • id (required, so that we can provide HAL links)
  • name
  • city (corresponding to the locations.city value of the matching locations record)

If we were writing a SQL statement to pull all users, it might look like the following:

SELECT u.id, u.name, l.city
FROM users u
LEFT JOIN locations l
    ON u.location_id = l.id

Similarly, a statement for retrieving a single user would look like this:

SELECT u.id, u.name, l.city
FROM users u
LEFT JOIN locations l
    ON u.location_id = l.id
WHERE u.id = ?

Creating a JOIN

zend-db provides SQL abstraction, which includes abstraction for JOIN statements. Generally speaking, you will create a SQL instance, from which you will generate a Select object.

use Laminas\Db\Sql\Sql;

// Where $adapter is a Laminas\Db\Adapter\AdapterInterface instance
$sql = new Sql($adapter);
$select = $sql->select();

You will then use the zend-db SQL DSL to build the statement you wish to execute. To follow our original examples, we'll first generate a statement we can use to pull all users with their associated cities:

$select
    ->from(['u' => 'users'])
    ->columns(['id', 'name'])
    ->join(['l' => 'locations'], 'u.location_id = l.id', ['city']);

The above indicates we are selecting from the table "users" and aliasing it in the SQL statement to "u". From it, we are retriving the columns "id" and "name"; note that we do not need to prefix these, as zend-db will do that for us. Next, we tell it to perform a JOIN on the "locations" table (aliasing it to "l"), where the "location_id" from the "users" table matches the "id" from the "locations" table. Finally, we're telling zend-db we only want the "city" column from the "locations" table when we get the results.

Next, we'll generate a statement for retrieving a single user:

$select
    ->from(['u' => 'users'])
    ->columns(['id', 'name'])
    ->join(['l' => 'locations'], 'u.location_id = l.id', ['city'])
    ->where(['u.id' => $id]);

This looks almost identicial to the previous example. The primary difference is the new where() clause. Note that in this case, we do need to disambiguate the column we are testing against, and we use the alias to do so.

When using a vanilla zend-db adapter, you then would execute the following to get results from either of the above:

$statement = $sql->prepareStatementForSqlObject($select);
$results = $statement->execute();

If you are using a normal REST resource (not DB-Connected), and using zend-db or a table gateway to back it, you can likely pull the above into your existing resource, and, with a little work, have it returning your entities and collections.

For DB-Connected resources, though, we now need to integrate it into our API Tools application.

Integrating the JOIN

The first step is determining where and how to create the JOIN.

In default usage, DB-Connected services are single-table, and there are no facilities available to change behavior. In fact, when you create a DB-Connected service, it only creates the entity and collection classes, and configuration!

Behind every DB-Connected service are two classes. The first is a Laminas\ApiTools\DbConnectedResource, and the other is a Laminas\Db\TableGateway\TableGateway; the former delegates to the latter. Creation of these is configuration-driven: the configuration values generated when you create a DB-Connected resource are used to provide resource-specific behavior.

What we will be doing is replacing this configuration-driven approach with an explicit approach that provides extensions to these two classes.

To start, we will create a custom TableGateway implementation, Users\V1\Rest\Users\UsersTableGateway. We will use our knowledge of creating JOIN statements in zend-db from above to create two new methods, getUserWithCity($id), and getUsersWithCities().

// in modules/Users/src/V1/Rest/Users/UsersTableGateway.php:
namespace Users\V1\Rest\Users;

use Laminas\Db\TableGateway\TableGateway;
use Laminas\Paginator\Adapter\DbSelect;

class UsersTableGateway extends TableGateway
{
    public function getUserWithCity($id)
    {
        $table = $this->getTable();
        $select = $this->getSql()->select();
        $select
            ->columns(['id', 'name'])
            ->join(['l' => 'locations'], $table . '.location_id = l.id', ['city'])
            ->where(['users.id' => $id]);
        return $this->selectWith($select);
    }

    /**
     * @return DbSelect
     */
    public function getUsersWithCities()
    {
        $table = $this->getTable();
        $select = $this->getSql()->select();
        $select
            ->columns(['id', 'name'])
            ->join(['l' => 'locations'], $table . '.location_id = l.id', ['city']);

        return new DbSelect($select, $this->getAdapter(), $this->getResultSetPrototype());
    }
}

You'll note a couple differences in these implementations from the original pure zend-db examples we had earlier.

First, instead of manually instantiating a Laminas\Db\Sql\Sql instance, we pull it from the table gateway instance directly. This is useful, as it already has knowledge of both our adapter, and the table we want to pull from. This latter fact allows us to eliminate the from() statement.

The next difference is in how we get our results.

When retrieving a single user, we can use a method of the TableGateway itself, selectWith(). This method accepts a Laminas\Db\Sql\Select instance, and then returns a result set representing the results of executing the statement.

When retrieving multiple users, we can capitalize on the fact that we know that for our own context, we want to return a paginated set of results, and thus return a pagination adapter. The appropriate one for us is Laminas\Paginator\Adapter\DbSelect, which expects the Select instance itself, plus a database adapter and result set prototype — which we can retrieve from the table gateway itself.

So, we now have a TableGateway. How do we get it properly instantiated and configured, though? We need to make sure it is getting a HydratingResultSet so that we get back our UserEntity instances, and we need to make sure it's getting the correct table name and database adapter.

We can do that by creating a factory. A Laminas\Db\TableGateway\TableGateway instance expects up to five arguments:

  • The table name.
  • The zend-db adapter to use.
  • An array of features, if any are needed.
  • A result set prototype instance.
  • A Sql instance to use as a prototype.

api-tools generally provides the first four arguments only, and supplies a null value for the third. We'll do similarly in the factory we create.

// in modules/Users/src/V1/Rest/Users/UsersTableGatewayFactory.php:
namespace Users\V1\Rest\Users;

use Psr\Container\ContainerInterface;
use Laminas\Db\ResultSet\HydratingResultSet;
use Laminas\Hydrator\ArraySerializable;

class UsersTableGatewayFactory
{
    public function __invoke(ContainerInterface $container)
    {
        return new UsersTableGateway(
            'users',
            $container->get('user_locations'),
            null,
            $this->getResultSetPrototype($container)
        );
    }

    private function getResultSetPrototype(ContainerInterface $container)
    {
        $hydrators = $container->get('HydratorManager');
        $hydrator = $hydrators->get(ArraySerializable::class);
        return new HydratingResultSet($hydrator, new UsersEntity());
    }
}

We know all the values we need up front; it's just a matter of pulling what we need from the container and/or creating instances.

In modules/Users/config/module.config.php, update the service_manager configuration as follows:

'service_manager' => [
    'factories' => [
        Users\V1\Rest\Users\UsersTableGateway::class => Users\V1\Rest\Users\UsersTableGatewayFactory::class,
    ],
],

We now have a table gateway capable of producing what we need. We now need to tell the resource to use these new methods.

Updating the resource

As noted earlier, DB-Connected services use a configuration-backed Laminas\ApiTools\DbConnectedResource. api-tools creates an instance of that class which then performs the various operations you allow. In order to use our new table gateway functionality, we will need to extend that class, override the relevent methods, and tell the container to use our new class.

First, we will create a new class named after the resource already created for us, Users\V1\Rest\Users\UsersResource, making it an extension of DbConnectedResource; it will override the fetch() and fetchAll() methods.

// in modules/Users/src/V1/Rest/Users/UsersResource.php:
namespace Users\V1\Rest\Users;

use DomainException;
use Laminas\ApiTools\DbConnectedResource;

class UsersResource extends DbConnectedResource
{
    public function fetch($id)
    {
        $resultSet = $this->table->getUserWithCity($id);
        if ($resultSet->count() === 0) {
            throw new DomainException('User not found', 404);
        }
        return $resultSet->current();
    }

    public function fetchAll($data = [])
    {
        return new UsersCollection($this->table->getUsersWithCities());
    }
}

What is this class doing?

In fetch(), it retrieves a result set by executing the table gateway's getUserWithCity() method, passing it the identifier. If the result has no rows, we throw an exception, but otherwise, return the first found.

In fetchAll() we create and return a new UsersCollection instance that uses the results of calling the table gateway's getUsersWithCities() method.

These methods are specific to our table gateway implementation above, so we need to create a factory for this resource, too, to ensure we get the appropriate instance.

A DbConnectedResource expects three constructor arguments:

  • The table gateway instance to use.
  • The name of the property in the returned entity instances that represents the identifier.
  • The name of the collection class.

Let's create a factory that provides these:

// in modules/Users/src/V1/Rest/Users/UsersResourceFactory.php:
namespace Users\V1\Rest\Users;

use Psr\Container\ContainerInterface;

class UsersResourceFactory
{
    public function __invoke(ContainerInterface $container)
    {
        return new UsersResource(
            $container->get(UsersTableGateway::class),
            'id',
            UsersCollection::class
        );
    }
}

We also need to wire this into the service manager, so edit the modules/Users/config/module.config.php file again:

'service_manager' => [
    'factories' => [
        Users\V1\Rest\Users\UsersResource::class => Users\V1\Rest\Users\UsersResourceFactory::class,
        Users\V1\Rest\Users\UsersTableGateway::class => Users\V1\Rest\Users\UsersTableGatewayFactory::class,
    ],
],

Cleanup

What we have done at this point is take a resource originally defined as a DB-Connected resource, and make it into a normal REST resource. None of the api-tools.db-connected configuration for our Users\V1\Rest\Users\UsersResource is relevant any more, as we are now defining the resource and its dependencies explicitly. As such, you can remove that entry from the configuration, and will find everything continues to work.

At this point, we have a REST resource that will successfully perform a LEFT JOIN on two tables, combining the information into a single entity (or collection of entities) to return. The REST resource, while it started out as a DB-Connected resource, is now fully managed by us, albeit by extending core classes to do so.

Images in the documentation, and the API Tools Admin UI itself, still refer to Apigility. This is due to the fact that we only recently transitioned the project to its new home in the Laminas API Tools. Rest assured that the functionality remains the same.