SQL Queries
Most of the time you will be using the ORM abstraction layer to interact with the database (see Introduction to the Data Model and ORM), but sometimes you may need to do something very complex or specific which is hard to do with that abstraction.
Silverstripe CMS provides a lower level abstraction layer, which is used by the Silverstripe CMS ORM internally.
Dealing with low-level SQL is not encouraged, since the ORM provides powerful abstraction APIs. Records in collections are lazy loaded, and these collections have the ability to run efficient SQL such as counts or returning a single column.
For example, if you want to run a simple COUNT
SQL statement,
the following three statements are functionally equivalent:
use SilverStripe\ORM\DB;
use SilverStripe\ORM\Queries\SQLSelect;
use SilverStripe\Security\Member;
// Get the table for the "Member" class with ANSI quotes
$memberTable = DB::get_conn()->escapeIdentifier(
DataObject::getSchema()->tableName(Member::class)
);
// Through raw SQL.
$count = DB::query('SELECT COUNT(*) FROM ' . $memberTable)->value();
// Through SQLSelect abstraction layer.
$count = SQLSelect::create('COUNT(*)', $memberTable)->execute()->value();
// Through the ORM.
$count = Member::get()->count();
If you do use raw SQL, you'll run the risk of breaking various assumptions the ORM and code based on it have:
- Custom getters/setters (object property values can differ from database column values)
- DataObject hooks like
onBeforeWrite()
andonBeforeDelete()
if running low-levelINSERT
orUPDATE
queries - Automatic casting
- Default values set through objects
- Database abstraction (some
DataObject
classes may not have their own tables, or may need aJOIN
with other tables to get all of their field values)
We'll explain some ways to use the low-level APIs with the full power of SQL, but still maintain a connection to the ORM where possible.
Usage
Getting table names
While you could hardcode table names into your SQL queries, that invites human error and means you have to make sure you know exactly what table stores which data for every class in the class hierarchy of the model you're interested in. Luckily, the DataObjectSchema
class knows all about the database schema for your DataObject
models. The following methods in particular may be useful to you:
baseDataTable()
: Get the name of the database table which holds the base data (i.e.ID
,ClassName
,Created
, etc) for a givenDataObject
classclassHasTable()
: Check if there is a table in the database for a givenDataObject
class (i.e. whether that class defines columns not already present in another class further up the class hierarchy)sqlColumnForField()
: Get the ANSI-quoted table and column name for a givenDataObject
field (in"Table"."Field"
format)tableForField()
: Get the table name in the class hierarchy which contains a given field column.tableName()
: Get table name for the given class. Note that this does not confirm a table actually exists (or should exist), but returns the name that would be used if this table did exist. Male sure to callclassHasTable()
before using this table name in a query.
PostgreSQLDatabase
which explicitly require ANSI quoted table names.
You can do that by passing the raw table name into DB::get_conn()->escapeIdentifier()
, which will ensure it is correctly escaped according to the rules of the currently active database connector.
SELECT
Selection can be done by creating an instance of SQLSelect
, which allows
management of all elements of a SQL SELECT
query, including columns, joined tables,
conditional filters, grouping, limiting, and sorting.
E.g:
$schema = DataObject::getSchema();
$playerTableName = DB::get_conn()->escapeIdentifier($schema->baseDataTable(Player::class));
$sqlQuery = new SQLSelect();
$sqlQuery->setFrom($playerTableName);
// Add a column to the `SELECT ()` clause
$sqlQuery->selectField('FieldName');
// You can pass an alias for the field in as the second argument
$sqlQuery->selectField('YEAR("Birthday")', 'Birthyear');
// Join another table onto the query
$joinOnClause = $schema->sqlColumnForField(Player::class, 'TeamID') . ' = ' . $schema->sqlColumnForField(Team::class, 'ID');
$sqlQuery->addLeftJoin($teamTableName, $joinOnClause);
// There are methods for most SQL clauses, such as WHERE, ORDER BY, GROUP BY, etc
$sqlQuery->addWhere(['YEAR("Birthday") = ?' => 1982]);
// $sqlQuery->setOrderBy(...);
// $sqlQuery->setGroupBy(...);
// $sqlQuery->setHaving(...);
// $sqlQuery->setLimit(...);
// $sqlQuery->setDistinct(true);
// Get the raw SQL (optional) and parameters
$rawSQL = $sqlQuery->sql($parameters);
// Execute and return a Query object
$result = $sqlQuery->execute();
// Iterate over results
foreach($result as $row) {
echo $row['BirthYear'];
}
addWhere()
method's PHPDoc includes multiple examples of different syntaxes that can be passed into it.
The result of SQLSelect::execute()
is an array lightly wrapped in a database-specific subclass of Query
.
This class implements the IteratorAggregate
interface, and provides convenience methods for accessing the data.
DELETE
Deletion can be done either by creating a SQLDelete
object, or by transforming a SQLSelect
into a SQLDelete
object instead.
For example, creating a SQLDelete
object:
use SilverStripe\CMS\Model\SiteTree;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DB;
use SilverStripe\ORM\Queries\SQLDelete;
$schema = DataObject::getSchema();
$siteTreeTable = DB::get_conn()->escapeIdentifier($schema->baseDataTable(SiteTree::class));
$query = SQLDelete::create()
->setFrom($siteTreeTable)
->setWhere([$schema->sqlColumnForField(SiteTree::class, 'ShowInMenus') => 0]);
$query->execute();
Alternatively, turning an existing SQLSelect
into a delete:
use SilverStripe\CMS\Model\SiteTree;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\Queries\SQLSelect;
$schema = DataObject::getSchema();
$siteTreeTable = DB::get_conn()->escapeIdentifier($schema->baseDataTable(SiteTree::class));
$query = SQLSelect::create()
->setFrom($siteTreeTable)
->setWhere([$schema->sqlColumnForField(SiteTree::class, 'ShowInMenus') => 0])
->toDelete();
$query->execute();
INSERT/UPDATE
INSERT
and UPDATE
can be performed using the SQLInsert
and SQLUpdate
classes.
These both have similar aspects in that they can modify content in
the database, but each are different in the way in which they behave.
These operations can be performed in batches by using the DB::manipulate
method, which internally uses SQLUpdate
/ SQLInsert
.
Each of these classes implement the SQLWriteExpression
interface, noting that each
accepts key/value pairs in a number of similar ways. These include the following
API methods:
addAssignments()
- Takes a list of assignments as an associative array of key => value pairs, where the value can also be an SQL expression.setAssignments()
- Replaces all existing assignments with the specified listgetAssignments()
- Returns all currently given assignments, as an associative array in the format['Column' => ['SQL' => ['parameters]]]
assign()
- Singular form ofaddAssignments()
, but only assigns a single column valueassignSQL()
- Assigns a column the value of a specified SQL expression without parameters -assignSQL('Column', 'SQL')
is shorthand forassign('Column', ['SQL' => []])
SQLUpdate
also includes the following API methods:
clear()
- Clears all assignmentsgetTable()
- Gets the table to updatesetTable()
- Sets the table to update (this should be ANSI-quoted) e.g.$query->setTable('"Page"');
SQLInsert
also includes the following API methods:
clear()
- Clears all rowsclearRow()
- Clears all assignments on the current rowaddRow()
- Adds another row of assignments, and sets the current row to the new rowaddRows()
- Adds a number of arrays, each representing a list of assignment rows, and sets the current row to the last onegetColumns()
- Gets the names of all distinct columns assignedgetInto()
- Gets the table to insert intosetInto()
- Sets the table to insert into (this should be ANSI-quoted), e.g.$query->setInto('"Page"');
E.g.:
use SilverStripe\CMS\Model\SiteTree;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DB;
use SilverStripe\ORM\Queries\SQLUpdate;
$schema = DataObject::getSchema();
$siteTreeTable = DB::get_conn()->escapeIdentifier($schema->baseDataTable(SiteTree::class));
$update = SQLUpdate::create($siteTreeTable)->addWhere(['"ID"' => 3]);
// assigning a list of items
$update->addAssignments([
'"Title"' => 'Our Products',
'"MenuTitle"' => 'Products'
]);
// Assigning a single value
$update->assign('"MenuTitle"', 'Products');
// Assigning a value using parameterised expression
$title = 'Products';
$update->assign('"MenuTitle"', [
'CASE WHEN LENGTH("MenuTitle") > LENGTH(?) THEN "MenuTitle" ELSE ? END' =>
[$title, $title]
]);
// Assigning a value using a pure SQL expression
$update->assignSQL('"Date"', 'NOW()');
// Perform the update
$update->execute();
In addition to assigning values, the SQLInsert
object also supports multi-row
inserts. For database connectors and API that don't have multi-row insert support
these are translated internally as multiple single row inserts.
For example:
use SilverStripe\CMS\Model\SiteTree;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DB;
use SilverStripe\ORM\Queries\SQLInsert;
$schema = DataObject::getSchema();
$siteTreeTable = DB::get_conn()->escapeIdentifier($schema->baseDataTable(SiteTree::class));
$insert = SQLInsert::create($siteTreeTable);
// Add multiple rows in a single call. Note that column names do not need to be symmetric
$insert->addRows([
['"Title"' => 'Home', '"Content"' => '<p>This is our home page</p>'],
['"Title"' => 'About Us', '"ClassName"' => 'AboutPage']
]);
// Adjust an assignment on the last row
$insert->assign('"Content"', '<p>This is about us</p>');
// Add another row
$insert->addRow(['"Title"' => 'Contact Us']);
// $columns will be ['"Title"', '"Content"', '"ClassName"'];
$columns = $insert->getColumns();
$insert->execute();
Value Checks
Raw SQL is handy for performance-optimized calls, e.g. when you want a single column rather than a full-blown object representation.
Example: Get the count from a relationship.
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DB;
use SilverStripe\ORM\Queries\SQLSelect;
$schema = DataObject::getSchema();
$playerTableName = DB::get_conn()->escapeIdentifier($schema->baseDataTable(Player::class));
$teamTableName = DB::get_conn()->escapeIdentifier($schema->baseDataTable(Team::class));
$playerIdField = $schema->sqlColumnForField(Player::class, 'ID');
$playerTeamIdField = $schema->sqlColumnForField(Player::class, 'TeamID');
$teamIdField = $schema->sqlColumnForField(Team::class, 'ID');
$sqlQuery = new SQLSelect();
$sqlQuery->setFrom($playerTableName);
$sqlQuery->addSelect('COUNT(' . $playerIdField . ')');
$sqlQuery->addWhere([$teamIdField => 99]);
$sqlQuery->addLeftJoin('Team', $teamIdField ' = ' . $playerTeamIdField);
$count = $sqlQuery->execute()->value();
Note that in the ORM, this call would be executed in an efficient manner as well:
$count = $myTeam->Players()->count();
Value placeholders
In some of the examples here you will have noticed a ?
as part of the query, which is a placeholder for a value. This is called a "parameterized" or "prepared" query and is a good way to make sure your values are correctly escaped automatically to help protect yourself against SQL injection attacks.
With some queries you'll know ahead of time how many values you're including in your query, but sometimes (most notably when using the IN
SQL operator) you will have a lot of values or a variable number of values and it can be difficult to get the correct number of ?
placeholders.
In those cases, you can use the DB::placeholders()
method, which prepares these placeholders for you.
IN
SQL operation, you can pass a custom delimiter as the second argument to DB::placeholders()
.
Also note that you can pass an integer in as the first argument rather than an array of values, if you want.
Example: Get the fields for all players in a team which has more than 15 wins.
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DB;
use SilverStripe\ORM\Queries\SQLSelect;
$schema = DataObject::getSchema();
$playerTableName = DB::get_conn()->escapeIdentifier($schema->baseDataTable(Player::class));
$teamIds = Team::get()->filter('Wins:GreaterThan', 15)->column('ID');
$placeholders = DB::placeholders($teamIds);
$sqlQuery = new SQLSelect();
$sqlQuery->setFrom($playerTableName)->where([
$schema->sqlColumnForField(Player::class, 'ID') . ' in (' . $placeholders . ')' => $ids
]);
$results = $sqlQuery->execute();
$players = Player::get()->filter('Teams.Wins:GreaterThan', 15);
Joining tables for a DataObject inheritance chain
In the Introduction to the Data Model and ORM we discussed how DataObject
inheretance chains can spread their data across multiple tables. The ORM handles this seemlessly, but when using the lower-level APIs we need to account for this ourselves by joining all of the relevant tables manually.
We also want to make sure to only select the records which are relevant for the actual class in the class hierarchy we're looking at. To do that, we can either use an INNER JOIN
, or we can use a WHERE
clause on the ClassName
field. In the below example we're using a WHERE
clause with a LEFT JOIN
because it is likely more intuitive for developers who aren't intimately familar with SQL.
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DB;
use SilverStripe\ORM\Queries\SQLSelect;
$schema = DataObject::getSchema();
$computerBaseTable = DB::get_conn()->escapeIdentifier($schema->baseDataTable(Computer::class));
$select = new SQLSelect();
$select->setFrom($computerTable);
$select->addWhere([$schema->sqlColumnForField(Computer::class, 'ClassName') => Computer::class]);
// Get all fields included in the query
$columns = $select->getSelect();
// If we're doing a "SELECT *" (which is the default select), get the field names from the DataObjectSchema instead
if (count($columns) === 1 && array_key_first($columns) === '*') {
$columns = $schema->fieldSpecs(Computer::class);
}
// Make sure we join all the tables in the inheritance chain which are required for this query
foreach ($columns as $alias => $ansiQuotedColumn) {
if ($schema->fieldSpec(Computer::class, $alias, DataObjectSchema::DB_ONLY)) {
$fieldTable = $schema->tableForField(Computer::class, $alias);
if (!$select->isJoinedTo($fieldTable)) {
$quotedFieldTable = DB::get_conn()->escapeIdentifier($fieldTable);
$joinOnClause = $schema->sqlColumnForField(Computer::class, 'ID') . ' = ' . $quotedFieldTable . '."ID"';
$select->addLeftJoin($quotedFieldTable, $joinOnClause);
}
}
}
Product::get()
where Product
is the first subclass of DataObject
- see the example in the Introduction to the Data Model and ORM), we can do this by using a LEFT JOIN
(like above), ommitting the WHERE
clause on the ClassName
field, and making sure we join all tables for the inheritance chain regardless of the fields being selected. To do that, make sure you're using the first DataObject
class as your first main query class (replace Computer
above with Product
, in this example), remove the call to $select->addWhere()
, and add the following code to the end of the above example:
// Make sure we join all the tables for the model inheritance chain
foreach (ClassInfo::subclassesFor(Product::class, includeBaseClass: false) as $class) {
if ($schema->classHasTable($class)) {
$classTable = $schema->tableName($class);
if (!$select->isJoinedTo($classTable)) {
$quotedClassTable = DB::get_conn()->escapeIdentifier($classTable);
$joinOnClause = $schema->sqlColumnForField(Product::class, 'ID') . ' = ' . $quotedClassTable . '."ID"';
$select->addLeftJoin($quotedClassTable, $joinOnClause);
}
}
}
Mapping
Creates a map based on the first two columns of the query result. This can be useful for creating dropdowns.
Example: Show player names with their birth year, but set their birth dates as values.
use SilverStripe\Forms\DropdownField;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DB;
use SilverStripe\ORM\Queries\SQLSelect;
$schema = DataObject::getSchema();
$playerTableName = DB::get_conn()->escapeIdentifier($schema->baseDataTable(Player::class));
$sqlQuery = new SQLSelect();
$sqlQuery->setFrom($playerTableName);
$sqlQuery->setSelect('"ID"');
$sqlQuery->selectField('CONCAT("Name", \' - \', YEAR("Birthdate")', 'NameWithBirthyear');
$map = $sqlQuery->execute()->map();
// The value of the selected option will be the record ID, and the display label will be the name and birthyear concatenation.
$field = new DropdownField('Birthdates', 'Birthdates', $map);
Note that going through SQLSelect
is only necessary here
because of the custom SQL value transformation (YEAR()
).
An alternative approach would be a custom getter in the object definition:
use SilverStripe\ORM\DataObject;
class Player extends DataObject
{
private static $db = [
'Name' => 'Varchar',
'Birthdate' => 'Date'
];
public function getNameWithBirthyear()
{
return date('y', $this->Birthdate);
}
}
$map = Player::get()->map('ID', 'NameWithBirthyear');
True raw SQL
Up until now we've still been using an abstraction layer to perform SQL queries - but there might be times where it's just cleaner to explicitly use raw SQL. You can do that with either the DB::query()
or DB::prepared_query()
method.
Directly querying the database:
use SilverStripe\CMS\Model\SiteTree;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DB;
$schema = DataObject::getSchema();
$siteTreeBaseTable = DB::get_conn()->escapeIdentifier($schema->baseDataTable(SiteTree::class));
$showInMenusField = $schema->sqlColumnForField(SiteTree::class, 'ShowInMenus');
// Use DB::query() if you don't need to pass in any parameters (values)
$count = DB::query('SELECT COUNT(*) FROM ' . $siteTreeBaseTable)->value();
// Use DB::prepared_query() if you need to pass in some parameters (values) e.g. for WHERE clauses
$results = DB::prepared_query('DELETE FROM ' . $siteTreeBaseTable . ' WHERE ' . $showInMenusField . ' = ?', [0]);
foreach ($results as $row) {
// $row is an array representing the database row, just like with SQLSelect.
}
execute()
with these methods, unlike the abstraction layer in the other examples. This is because you're passing the entire query into the method - you can't change the query after it's passed in, so it gets executed right away. The return type for these methods is the same as the return type for the execute()
methods on the SQLExpression
classes.
Data types
The following PHP types are used to return database content:
- booleans will be an integer 1 or 0, to ensure consistency with MySQL that doesn't have native booleans
- integer types returned as integers
- floating point / decimal types returned as floats
- strings returned as strings
- dates / datetimes returned as strings