SQL Extensions

Databases that support SQL language can use PersistenceSql. This driver will format queries to the database using SQL language.

In addition to normal operations you can extend and customize various queries.

Default Model Classes

When using PersistenceSql model building will use different classes for fields, expressions, joins etc:

  • addField - FieldSql (field can be used as part of DSQL Expression)
  • hasOne - ReferenceHasOneSql (allow importing fields)
  • addExpression - SqlExpressionField (define expression through DSQL)
  • join - JoinSql (join tables query-time)

SQL Field

class FieldSql
property FieldSql::$actual

PersistenceSql supports field name mapping. Your field could have different column name in your schema:

$this->addField('name', ['actual' => 'first_name']);

This will apply to load / save operations as well as query mapping.

FieldSql::getDsqlExpression()

SQL Fields can be used inside other SQL expressions:

$q = $connection->expr('[age] + [birth_year]', [
        'age' => $m->getField('age'),
        'birth_year' => $m->getField('birth_year'),
    ]);

SQL Reference

class ReferenceHasOneSql

Extends ReferenceHasOne

ReferenceHasOneSql::addField()

Allows importing field from a referenced model:

$model->hasOne('country_id', ['model' => [Country::class]])
    ->addField('country_name', 'name');

Second argument could be array containing additional settings for the field:

$model->hasOne('account_id', ['model' => [Account::class]])
    ->addField('account_balance', ['balance', 'type' => 'atk4_money']);

Returns new field object.

ReferenceHasOneSql::addFields()

Allows importing multiple fields:

$model->hasOne('country_id', ['model' => [Country::class]])
    ->addFields(['country_name', 'country_code']);

You can specify defaults to be applied on all fields:

$model->hasOne('account_id', ['model' => [Account::class]])
    ->addFields([
        'opening_balance',
        'balance',
    ], ['type' => 'atk4_money']);

You can also specify aliases:

$model->hasOne('account_id', ['model' => [Account::class]])
    ->addFields([
        'opening_balance',
        'account_balance' => 'balance',
    ], ['type' => 'atk4_money']);

If you need to pass more details to individual field, you can also use sub-array:

$model->hasOne('account_id', ['model' => [Account::class]])
    ->addFields([
    [
        ['opening_balance', 'caption' => 'The Opening Balance'],
        'account_balance' => 'balance',
    ], ['type' => 'atk4_money']);

Returns $this.

ReferenceHasOneSql::ref()

While similar to ReferenceHasOne::ref this implementation implements deep traversal:

$countryModel = $customerModel->addCondition('is_vip', true)
    ->ref('country_id'); // $model was not loaded!

Creates a model for related entity with applied condition referencing field of a current model through SQL expression rather then value. This is usable if you are creating sub-queries.

ReferenceHasOneSql::addTitle()

Similar to addField, but will import “title” field and will come up with good name for it:

$model->hasOne('country_id', ['model' => [Country::class]])
    ->addTitle();

// creates 'country' field as sub-query for country.name

You may pass defaults:

$model->hasOne('country_id', ['model' => [Country::class]])
    ->addTitle(['caption' => 'Country Name']);

Returns new field object.

Expressions

class SqlExpressionField

Extends FieldSql

Expression will map into the SQL code, but will perform as read-only field otherwise.

property SqlExpressionField::$expr

Stores expression that you define through DSQL expression:

$model->addExpression('age', ['expr' => 'year(now()) - [birth_year]']);
// tag [birth_year] will be automatically replaced by respective model field
SqlExpressionField::getDsqlExpression()

SQL Expressions can be used inside other SQL expressions:

$model->addExpression('can_buy_alcohol', ['expr' => 'if([age] > 25, 1, 0)', 'type' => 'boolean']);

Adding expressions to model will make it automatically reload itself after save as default behavior, see Model::reloadAfterSave.

Transactions

class PersistenceSql
PersistenceSql::atomic()

This method allows you to execute code within a ‘START TRANSACTION / COMMIT’ block:

class Invoice
{
    public function applyPayment(Payment $p)
    {
        $this->getModel()->getPersistence()->atomic(function () use ($p) {
            $this->set('paid', true);
            $this->save();

            $p->set('applied', true);
            $p->save();
        });
    }
}

Callback format of this method allows a more intuitive syntax and nested execution of various blocks. If any exception is raised within the block, then transaction will be automatically rolled back. The return of atomic() is same as return of user-defined callback.

Custom Expressions

PersistenceSql::expr()

This method is also injected into the model, that is associated with PersistenceSql so the most convenient way to use this method is by calling $model->expr(‘foo’).

This method is quite similar to Atk4DataPersistenceSqlQuery::expr() method explained here: http://dsql.readthedocs.io/en/stable/expressions.html

There is, however, one difference. Expression class requires all named arguments to be specified. Use of Model::expr() allows you to specify field names and those field expressions will be automatically substituted. Here is long / short format:

$q = $connection->expr('[age] + [birth_year]', [
        'age' => $m->getField('age'),
        'birth_year' => $m->getField('birth_year'),
    ]);

// identical to

$q = $m->expr('[age] + [birth_year']);

This method is automatically used by SqlExpressionField.

Actions

The most basic action you can use with SQL persistence is ‘select’:

$action = $model->action('select');

Action is implemented by DSQL library, that is further documented at http://dsql.readthedocs.io (See section Queries).

Action: select

This action returns a basic select query. You may pass one argument - array containing list of fields:

$action = $model->action('select', ['name', 'surname']);

Passing false will not include any fields into select (so that you can include them yourself):

$action = $model->action('select', [false]);
$action->field('count(*)', 'c);

Action: count

Returns query for count(*):

$action = $model->action('count');
$cnt = $action->getOne();
// for materialized count use:
$cnt = $model->executeCountQuery();

You can also specify alias:

$action = $model->action('count', ['alias' => 'cc']);
$data = $action->getRow();
$cnt = $data->get('cc');

Action: field

Get query for a specific field:

$action = $model->action('field', ['age']);
$age = $action->limit(1)->getOne();

You can also specify alias:

$action = $model->action('field', ['age', 'alias' => 'the_age']]);
$age = $action->limit(1)->getRow()['the_age'];

Action: fx

Executes single-argument SQL function on field:

$action = $model->action('fx', ['avg', 'age']);
$ageAvg = $action->getOne();

This method also supports alias. Use of alias is handy if you are using those actions as part of other query (e.g. UNION)

Stored Procedures

SQL servers allow to create and use stored procedures and there are several ways to invoke them:

  1. CALL procedure. No data / output.
  2. Specify OUT parameters.
  3. Stored FUNCTION, e.g. select myfunc(123)
  4. Stored procedures that return data.

Agile Data has various ways to deal with above scenarios:

  1. Custom expression through DSQL
  2. Model Method
  3. Model Field
  4. Model Source

Here I’ll try to look into each of those approaches but closely pay attention to the following:

  • Abstraction and concern separation.
  • Security and protecting against injection.
  • Performance and scalability.
  • When to refactor away stored procedures.

Compatibility Warning

Agile Data is designed to be cross-database agnostic. That means you should be able to swap your SQL to NoSQL or RestAPI at any moment. My relying on stored procedures you will loose portability of your application.

We do have our legacy applications to maintain, so Stored Procedures and SQL extensions are here to stay. By making your Model rely on those extensions you will loose ability to use the same model with non-sql persistencies.

Sometimes you can fence the code like this:

if ($this->getPersistence() instanceof \Atk4\Data\Persistence\Sql) {
    .. sql code ..
}

Or define your pure model, then extend it to add SQL capabilities. Note that using single model with cross-persistencies should still be possible, so you should be able to retrieve model data from stored procedure then cache it.

as a Model method

You should be familiar with http://dsql.readthedocs.io/en/develop/expressions.html.

In short this should allow you to build and execute any SQL statement:

$this->expr('call get_nominal_sheet([], [], \'2014-10-01\', \'2015-09-30\', 0)', [
    $this->getApp()->system->getId(),
    $this->getApp()->system['contractor_id'],
])->executeQuery();

Depending on the statement you can also use your statement to retrieve data:

$data = $this->expr('call get_client_report_data([client_id])', [
    'client_id' => $clientId,
])->getRows();

This can be handy if you wish to create a method for your Model to abstract away the data:

class Client extends \Atk4\Data\Model
{
    protected function init(): void
    {
        ...
    }

    public function getReportData($arg)
    {
        $this->assertIsLoaded();

        return $this->expr('call get_client_report_data([client_id], [arg])', [
            'arg' => $arg,
            'client_id' => $clientId,
        ])->getRows();
    }
}

Here is another example using PHP generator:

class Client extends \Atk4\Data\Model
{
    protected function init(): void
    {
        ...
    }

    public function fetchReportData($arg)
    {
        $this->assertIsLoaded();

        foreach ($this->expr('call get_client_report_data([client_id], [arg])', [
            'arg' => $arg,
            'client_id' => $clientId,
        ]) as $row) {
            yield $row;
        }
    }
}

as a Model Field

Important

Not all SQL vendors may support this approach.

Model::addExpression is a SQL extension that allow you to define any expression for your field query. You can use SQL stored function for data fetching like this:

class Category extends \Atk4\Data\Model
{
    public $table = 'category';

    protected function init(): void
    {
        parent::init();

        $this->hasOne('parent_id', ['model' => [self::class]]);
        $this->addField('name');

        $this->addExpression('path', ['expr' => 'get_path([id])']);
    }
}

This should translate into SQL query:

select parent_id, name, get_path(id) from category;

where once again, stored function is hidden.

as an Action

Important

Not all SQL vendors may support this approach.

Method PersistenceSql::action and Model::action generates queries for most of model operations. By re-defining this method, you can significantly affect the query building of an SQL model:

class CompanyProfit extends \Atk4\Data\Model
{
    public $companyId; // inject company ID, which will act as a condition/argument
    public bool $readOnly = true; // instructs rest of the app, that this model is read-only

    protected function init(): void
    {
        parent::init();

        $this->addField('date_period');
        $this->addField('profit');
    }

    public function action(string $mode, array $args = [])
    {
        if ($mode == 'select') {
            // must return DSQL object here
            return $this->expr('call get_company_profit([company_id])', [
                'company_id' => $this->companyId,
            ]);
        }

        if ($mode == 'count') {
            // optionally - expression for counting data rows, for pagination support
            return $this->expr('select count(*) from (call get_company_profit([company_id]))', [
                'company_id' => $this->companyId,
            ]);
        }

        throw (new \Atk4\Data\Exception('You may only perform "select" or "count" action on this model'))
            ->addMoreInfo('action', $mode);
    }
}

as a Temporary Table

A most convenient (although inefficient) way for stored procedures is to place output data inside a temporary table. You can perform an actual call to stored procedure inside Model::init() then set $table property to a temporary table:

class NominalReport extends \Atk4\Data\Model
{
    public $table = 'temp_nominal_sheet';
    public bool $readOnly = true; // instructs rest of the app, that this model is read-only

    protected function init(): void
    {
        parent::init();

        $res = $this->expr('call get_nominal_sheet([], [], \'2014-10-01\', \'2015-09-30\', 0)', [
            $this->getApp()->system->getId(),
            $this->getApp()->system['contractor_id'],
        ])->executeQuery();

        $this->addField('date', ['type' => 'date']);
        $this->addField('items', ['type' => 'integer']);
        ...
    }
}

as an Model Source

Important

Not all SQL vendors may support this approach.

Technically you can also specify expression as a $table property of your model:

class ClientReport extends \Atk4\Data\Model
{
    public $table; // will be set in init()
    public bool $readOnly = true; // instructs rest of the app, that this model is read-only

    protected function init(): void
    {
        parent::init();

        $this->init = $this->expr('call get_report_data()');

        $this->addField('date', ['type' => 'date']);
        $this->addField('items', ['type' => 'integer']);
        ...
    }
}

Technically this will give you select date, items from (call get_report_data()).