Database Explorer
Nette Database Explorer significantly simplifies retrieving data from the database without writing SQL queries.
- uses efficient queries
- no data is transmitted unnecessarily
- features elegant syntax
To use Database Explorer, start with a table – call table()
on a Nette\Database\Explorer object. The easiest way to
get a context object instance is described
here, or, for case when Nette Database Explorer is used as a standalone tool, it can be created manually.
In version 3.0 the class was called Nette\Database\Context
.
$books = $explorer->table('book'); // db table name is 'book'
The call returns an instance of Selection object, that can be iterated over to retrieve all the books. Each item (a row) is represented by an instance of ActiveRow with data mapped to its properties:
foreach ($books as $book) {
echo $book->title;
echo $book->author_id;
}
Getting just one specific row is done by get()
method, which directly returns an ActiveRow instance.
$book = $explorer->table('book')->get(2); // returns book with id 2
echo $book->title;
echo $book->author_id;
Let's take a look at common use-case. You need to fetch books and their authors. It is a common 1:N relationship. The often used solution is to fetch data using one SQL query with table joins. The second possibility is to fetch data separately, run one query for getting books and then get an author for each book by another query (e.g. in your foreach cycle). This could be easily optimized to run only two queries, one for the books, and another for the needed authors – and that is exactly the way how Nette Database Explorer does it.
In the examples below, we will work with the database schema in the figure. There are OneHasMany (1:N) links (author of book
author_id
and possible translator translator_id
, which may be null
) and ManyHasMany (M:N)
link between book and its tags.
An example, including a schema, is found on GitHub.
Database structure used in the examples
The following code lists the author's name for each book and all its tags. We will discuss in a moment how this works internally.
$books = $explorer->table('book');
foreach ($books as $book) {
echo 'title: ' . $book->title;
echo 'written by: ' . $book->author->name; // $book->author is row from table 'author'
echo 'tags: ';
foreach ($book->related('book_tag') as $bookTag) {
echo $bookTag->tag->name . ', '; // $bookTag->tag is row from table 'tag'
}
}
You will be pleased how efficiently the database layer works. The example above makes a constant number of requests that look like this:
SELECT * FROM `book`
SELECT * FROM `author` WHERE (`author`.`id` IN (11, 12))
SELECT * FROM `book_tag` WHERE (`book_tag`.`book_id` IN (1, 4, 2, 3))
SELECT * FROM `tag` WHERE (`tag`.`id` IN (21, 22, 23))
If you use cache (defaults on), no columns will be queried unnecessarily. After the first query, cache will store the used column names and Nette Database Explorer will run queries only with the needed columns:
SELECT `id`, `title`, `author_id` FROM `book`
SELECT `id`, `name` FROM `author` WHERE (`author`.`id` IN (11, 12))
SELECT `book_id`, `tag_id` FROM `book_tag` WHERE (`book_tag`.`book_id` IN (1, 4, 2, 3))
SELECT `id`, `name` FROM `tag` WHERE (`tag`.`id` IN (21, 22, 23))
Selections
See possibilities how to filter and restrict rows Nette\Database\Table\Selection:
$table->where($where[, $param[, ...]]) |
Set WHERE using AND as a glue if two or more conditions are supplied |
$table->whereOr($where) |
Set WHERE using OR as a glue if two or more conditions are supplied |
$table->order($columns) |
Set ORDER BY, can be expression ('column DESC, id DESC') |
$table->select($columns) |
Set retrieved columns, can be expression ('col, MD5(col) AS hash') |
$table->limit($limit[, $offset]) |
Set LIMIT and OFFSET |
$table->page($page, $itemsPerPage[, &$lastPage]) |
Enables pagination |
$table->group($columns) |
Set GROUP BY |
$table->having($having) |
Set HAVING |
Fluent interface can be used, for example $table->where(...)->order(...)->limit(...)
. Multiple
where
or whereOr
conditions are connected with the AND
operator.
where()
Nette Database Explorer can automatically add needed operators for passed values:
$table->where('field', $value) |
field = $value |
$table->where('field', null) |
field IS NULL |
$table->where('field > ?', $val) |
field > $val |
$table->where('field', [1, 2]) |
field IN (1, 2) |
$table->where('id = ? OR name = ?', 1, $name) |
id = 1 OR name = ‘Jon Snow’ |
$table->where('field', $explorer->table($tableName)) |
field IN (SELECT $primary FROM $tableName) |
$table->where('field', $explorer->table($tableName)->select('col')) |
field IN (SELECT col FROM $tableName) |
You can provide placeholder even without column operator. These calls are the same.
$table->where('id = ? OR id = ?', 1, 2);
$table->where('id ? OR id ?', 1, 2);
This feature allows to generate correct operator based on value:
$table->where('id ?', 2); // id = 2
$table->where('id ?', null); // id IS NULL
$table->where('id', $ids); // id IN (...)
Selection correctly handles also negative conditions, works for empty arrays too:
$table->where('id', []); // id IS NULL AND FALSE
$table->where('id NOT', []); // id IS NULL OR TRUE
$table->where('NOT (id ?)', $ids); // NOT (id IS NULL AND FALSE)
// this will throws an exception, this syntax is not supported
$table->where('NOT id ?', $ids);
whereOr()
Example of use without parameters:
// WHERE (user_id IS NULL) OR (SUM(`field1`) > SUM(`field2`))
$table->whereOr([
'user_id IS NULL',
'SUM(field1) > SUM(field2)',
]);
We use the parameters. If you do not specify an operator, Nette Database Explorer will automatically add the appropriate one:
// WHERE (`field1` IS NULL) OR (`field2` IN (3, 5)) OR (`amount` > 11)
$table->whereOr([
'field1' => null,
'field2' => [3, 5],
'amount >' => 11,
]);
The key can contain an expression containing wildcard question marks and then pass parameters in the value:
// WHERE (`id` > 12) OR (ROUND(`id`, 5) = 3)
$table->whereOr([
'id > ?' => 12,
'ROUND(id, ?) = ?' => [5, 3],
]);
order()
Examples of use:
$table->order('field1'); // ORDER BY `field1`
$table->order('field1 DESC, field2'); // ORDER BY `field1` DESC, `field2`
$table->order('field = ? DESC', 123); // ORDER BY `field` = 123 DESC
select()
Examples of use:
$table->select('field1'); // SELECT `field1`
$table->select('col, UPPER(col) AS abc'); // SELECT `col`, UPPER(`col`) AS abc
$table->select('SUBSTR(title, ?)', 3); // SELECT SUBSTR(`title`, 3)
limit()
Examples of use:
$table->limit(1); // LIMIT 1
$table->limit(1, 10); // LIMIT 1 OFFSET 10
page()
An alternative way to set the limit and offset:
$page = 5;
$itemsPerPage = 10;
$table->page($page, $itemsPerPage); // LIMIT 10 OFFSET 40
Getting the last page number, passed to the $lastPage
variable:
$table->page($page, $itemsPerPage, $lastPage);
group()
Examples of use:
$table->group('field1'); // GROUP BY `field1`
$table->group('field1, field2'); // GROUP BY `field1`, `field2`
having()
Examples of use:
$table->having('COUNT(items) >', 100); // HAVING COUNT(`items`) > 100
Filtering by Another Table Value
Quite often you need filter results by some condition which involves another database table. These types of condition require table join. However, you don't need to write them anymore.
Let's say you need to get all books whose author's name is ‘Jon’. All you need to write is the joining key of the
relation and the column name in the joined table. The joining key is derived from the column which refers to the table you want to
join. In our example (see the db schema) it is the column author_id
, and it is sufficient to use just the first part
of it – author
(the _id
suffix can be omitted). name
is a column in the
author
table we would like to use. A condition for book translator (which is connected by translator_id
column) can be created just as easily.
$books = $explorer->table('book');
$books->where('author.name LIKE ?', '%Jon%');
$books->where('translator.name', 'David Grudl');
The joining key logic is driven by implementation of Conventions. We encourage to use DiscoveredConventions, which analyzes your foreign keys and allows you to easily work with these relationships.
The relationship between book and its author is 1:N. The reverse relationship is also possible. We call it backjoin.
Take a look at another example. We would like to fetch all authors, who have written more than 3 books. To make the join reverse
we use :
(colon). Colon means that the joined relationship means hasMany (and it's quite logical too, as two dots
are more than one dot). Unfortunately, the Selection class isn't smart enough, so we have to help with the aggregation and provide
a GROUP BY
statement, also the condition has to be written in form of HAVING
statement.
$authors = $explorer->table('author');
$authors->group('author.id')
->having('COUNT(:book.id) > 3');
You may have noticed that the joining expression refers to the book, but it's not clear, whether we are joining through
author_id
or translator_id
. In the example above, Selection joins through the author_id
column because a match with the source table has been found – the author
table. If there was no such a match and
there would be more possibilities, Nette would throw AmbiguousReferenceKeyException.
To make a join through translator_id
column, provide an optional parameter within the joining expression.
$authors = $explorer->table('author');
$authors->group('author.id')
->having('COUNT(:book(translator).id) > 3');
Let's take a look at some more difficult joining expression.
We would like to find all authors who have written something about PHP. All books have tags so we should select those authors who have written any book with the PHP tag.
$authors = $explorer->table('author');
$authors->where(':book:book_tags.tag.name', 'PHP')
->group('author.id')
->having('COUNT(:book:book_tags.tag.id) > 0');
Aggregate Queries
$table->count('*') |
Get number of rows |
$table->count("DISTINCT $column") |
Get number of distinct values |
$table->min($column) |
Get minimum value |
$table->max($column) |
Get maximum value |
$table->sum($column) |
Get the sum of all values |
$table->aggregation("GROUP_CONCAT($column)") |
Run any aggregation function |
The count()
method without any specified parameters selects all records and returns the array
size, which is very inefficient. For example, if you need to calculate the number of rows for paging, always specify the first
argument.
Escaping & Quoting
Database Explorer is smart and escape parameters and quotes identificators for you. These basic rules need to be followed, though:
- keywords, functions, procedures must be uppercase
- columns and tables must be lowercase
- pass variables as parameters, do not concatenate
->where('name like ?', 'John'); // WRONG! generates: `name` `like` ?
->where('name LIKE ?', 'John'); // CORRECT
->where('KEY = ?', $value); // WRONG! KEY is a keyword
->where('key = ?', $value); // CORRECT. generates: `key` = ?
->where('name = ' . $name); // WRONG! sql injection!
->where('name = ?', $name); // CORRECT
->select('DATE_FORMAT(created, "%d.%m.%Y")'); // WRONG! pass variables as parameters, do not concatenate
->select('DATE_FORMAT(created, ?)', '%d.%m.%Y'); // CORRECT
Wrong usage can produce security holes
Fetching Data
foreach ($table as $id => $row) |
Iterate over all rows in result |
$row = $table->get($id) |
Get single row with ID $id from table |
$row = $table->fetch() |
Get next row from the result |
$array = $table->fetchPairs($key, $value) |
Fetch all values to associative array |
$array = $table->fetchPairs($value) |
Fetch all rows to associative array |
count($table) |
Get number of rows in result set |
Insert, Update & Delete
Method insert()
accepts array of Traversable objects (for example ArrayHash which returns forms):
$row = $explorer->table('users')->insert([
'name' => $name,
'year' => $year,
]);
// INSERT INTO users (`name`, `year`) VALUES ('Jim', 1978)
If primary key is defined on the table, an ActiveRow object containing the inserted row is returned.
Multiple insert:
$explorer->table('users')->insert([
[
'name' => 'Jim',
'year' => 1978,
], [
'name' => 'Jack',
'year' => 1987,
]
]);
// INSERT INTO users (`name`, `year`) VALUES ('Jim', 1978), ('Jack', 1987)
Files or DateTime objects can be passed as parameters:
$explorer->table('users')->insert([
'name' => $name,
'created' => new DateTime, // or $explorer::literal('NOW()')
'avatar' => fopen('image.gif', 'r'), // inserts the file
]);
Updating (returns the count of affected rows):
$count = $explorer->table('users')
->where('id', 10) // must be called before update()
->update([
'name' => 'Ned Stark'
]);
// UPDATE `users` SET `name`='Ned Stark' WHERE (`id` = 10)
For update we can use operators +=
a -=
:
$explorer->table('users')
->update([
'age+=' => 1, // see +=
]);
// UPDATE users SET `age` = `age` + 1
Deleting (returns the count of deleted rows):
$count = $explorer->table('users')
->where('id', 10)
->delete();
// DELETE FROM `users` WHERE (`id` = 10)
Working with Relationships
Has One Relation
Has one relation is a common use-case. Book has one author. Book has one translator. Getting related row is
mainly done by ref()
method. It accepts two arguments: target table name and source joining column. See example:
$book = $explorer->table('book')->get(1);
$book->ref('author', 'author_id');
In example above we fetch related author entry from author
table, the author primary key is searched by
book.author_id
column. Ref() method returns ActiveRow instance or null if there is no appropriate entry. Returned row
is an instance of ActiveRow so we can work with it the same way as with the book entry.
$author = $book->ref('author', 'author_id');
$author->name;
$author->born;
// or directly
$book->ref('author', 'author_id')->name;
$book->ref('author', 'author_id')->born;
Book also has one translator, so getting translator name is quite easy.
$book->ref('author', 'translator_id')->name
All of this is fine, but it's somewhat cumbersome, don't you think? Database Explorer already contains the foreign keys definitions so why not use them automatically? Let's do that!
If we call property, which does not exist, ActiveRow tries to resolve the calling property name as ‘has one’ relation. Getting this property is the same as calling ref() method with just one argument. We will call the only argument the key. Key will be resolved to particular foreign key relation. The passed key is matched against row columns, and if it matches, foreign key defined on the matched column is used for getting data from related target table. See example:
$book->author->name;
// same as
$book->ref('author')->name;
The ActiveRow instance has no author column. All book columns are searched for a match with key. Matching in this case
means the column name has to contain the key. So in the example above, the author_id
column contains string
‘author’ and is therefore matched by ‘author’ key. If you want to get the book translator, just can use e.g.
‘translator’ as a key, because ‘translator’ key will match the translator_id
column. You can find more about
the key matching logic in Joining expressions chapter.
echo $book->title . ': ';
echo $book->author->name;
if ($book->translator) {
echo ' (translated by ' . $book->translator->name . ')';
}
If you want to fetch multiple books, you should use the same approach. Nette Database Explorer will fetch authors and translators for all the fetched books at once.
$books = $explorer->table('book');
foreach ($books as $book) {
echo $book->title . ': ';
echo $book->author->name;
if ($book->translator) {
echo ' (translated by ' . $book->translator->name . ')';
}
}
The code will run only these 3 queries:
SELECT * FROM `book`;
SELECT * FROM `author` WHERE (`id` IN (1, 2, 3)); -- ids of fetched books from author_id column
SELECT * FROM `author` WHERE (`id` IN (2, 3)); -- ids of fetched books from translator_id column
Has Many Relation
‘Has many’ relation is just reversed ‘has one’ relation. Author has written many books. Author has
translated many books. As you can see, this type of relation is a little bit more difficult because the relation is
‘named’ (‘written’, ‘translated’). ActiveRow instance has related()
method, which will return array of
related entries. Entries are also ActiveRow instances. See example bellow:
$author = $explorer->table('author')->get(11);
echo $author->name . ' has written:';
foreach ($author->related('book.author_id') as $book) {
echo $book->title;
}
echo 'and translated:';
foreach ($author->related('book.translator_id') as $book) {
echo $book->title;
}
Method related()
method accepts full join description passed as two arguments or as one argument joined by dot.
The first argument is the target table, the second is the target column.
$author->related('book.translator_id');
// same as
$author->related('book', 'translator_id');
You can use Nette Database Explorer heuristics based on foreign keys and provide only key argument. Key will be matched
against all foreign keys pointing into the current table (author
table). If there is a match, Nette Database Explorer
will use this foreign key, otherwise it will throw Nette\InvalidArgumentException or AmbiguousReferenceKeyException.
You can find more about the key matching logic in Joining expressions chapter.
Of course you can call related methods for all fetched authors, Nette Database Explorer will again fetch the appropriate books at once.
$authors = $explorer->table('author');
foreach ($authors as $author) {
echo $author->name . ' has written:';
foreach ($author->related('book') as $book) {
$book->title;
}
}
Example above will run only two queries:
SELECT * FROM `author`;
SELECT * FROM `book` WHERE (`author_id` IN (1, 2, 3)); -- ids of fetched authors
Creating Explorer Manually
A database connection can be created using the application configuration. In such cases a Nette\Database\Explorer
service is created and can be passed as a dependency using the DI container.
However, if Nette Database Explorer is used as a standalone tool, an instance of Nette\Database\Explorer
object
needs to be created manually.
// $storage implements Nette\Caching\Storage:
$storage = new Nette\Caching\Storages\FileStorage($tempDir);
$connection = new Nette\Database\Connection($dsn, $user, $password);
$structure = new Nette\Database\Structure($connection, $storage);
$conventions = new Nette\Database\Conventions\DiscoveredConventions($structure);
$explorer = new Nette\Database\Explorer($connection, $structure, $conventions, $storage);