SQL Extensions
Databases that support SQL language can use Persistence\Sql
.
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 Persistence\Sql
model building will use different classes for fields,
expressions, joins etc:
addField - different class is no longer used/needed
hasOne -
Reference\HasOneSql
(allow importing fields)addExpression -
Field\SqlExpressionField
(define expression through DSQL)join -
Persistence\Sql\Join
(join tables query-time)
SQL Field
- class Atk4\Data\Field
- property Atk4\Data\Field::$actual
Persistence\Sql
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.
- Atk4\Data\Field::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 Atk4\Data\Reference\HasOneSql
Extends
Reference\HasOne
- Atk4\Data\Reference\HasOneSql::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.
- Atk4\Data\Reference\HasOneSql::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.
- Atk4\Data\Reference\HasOneSql::ref()
While similar to
Reference\HasOne::ref
this implementation implements deep traversal:$countryModel = $customerModel->addCondition('is_vip', true) ->ref('country_id'); // $model was not loaded!
- Atk4\Data\Reference\HasOneSql::refLink()
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.
- Atk4\Data\Reference\HasOneSql::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
Expression will map into the SQL code, but will perform as read-only field otherwise.
- property Atk4\Data\Field\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
- Atk4\Data\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 Atk4\Data\Persistence\Sql
- Atk4\Data\Persistence\Sql::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
- Atk4\Data\Persistence\Sql::expr()
This method is also injected into the model, that is associated with
Persistence\Sql
so the most convenient way to use this method is by calling$model->expr('foo')
.
This method is quite similar to \Atk4\Data\Persistence\Sql\Query::expr() method.
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 Field\SqlExpressionField
.
Actions
The most basic action you can use with SQL persistence is ‘select’:
$action = $model->action('select');
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:
CALL
procedure. No data / output.Specify
OUT
parameters.Stored
FUNCTION
, e.g.select myfunc(123)
Stored procedures that return data.
Agile Data has various ways to deal with above scenarios:
Custom expression through DSQL
Model Method
Model Field
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
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 Persistence\Sql::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 a 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->table = $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())
.