├── .php_cs.dist
├── tests
├── .gitignore
├── sqlite.php
├── mysql.php
├── postgresql.php
├── TestConnection.php
├── ResultTest.php
├── BaseTest.php
├── FragmentTest.php
└── ConnectionTest.php
├── .gitignore
├── src
└── Dop
│ ├── Exception.php
│ ├── Result.php
│ ├── Fragment.php
│ └── Connection.php
├── .editorconfig
├── phpunit.xml
├── CHANGELOG.md
├── composer.json
├── .travis.yml
├── api.php
├── LICENSE.md
├── README.md
└── API.md
/.php_cs.dist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | tests
5 |
6 |
7 |
8 |
9 | src/Dop
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/tests/TestConnection.php:
--------------------------------------------------------------------------------
1 | test = $test;
9 | }
10 |
11 | public function execCallback($statement, $callback)
12 | {
13 | $this->test->beforeExec($statement);
14 | $callback();
15 | }
16 |
17 | protected $test;
18 | }
19 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## 0.4.0
4 |
5 | - PSR-4
6 | - Fixed bug in `WHERE` condition building
7 | - Fixed bug where `lastInsertId` would fail in PostgreSQL
8 | - Fixed bug when inserting doubles
9 | - Add `Connection::execCallback` for logging/measuring statements
10 | - Deprecate `Connection::beforeExec`
11 |
12 | ## 0.3.1
13 |
14 | - Fixed bug in invalid limit/offset exception messages
15 |
16 | ## v0.3.0
17 |
18 | - Fixed default param in `Result::fetch`
19 |
20 | ## v0.2.0
21 |
22 | - Revised API with breaking changes
23 |
24 | ## v0.1.2
25 |
26 | - Fixed raw SQL handling
27 |
28 | ## v0.1.1
29 |
30 | - Fixed numeric indexing of `Result::filter`
31 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "morris/dop",
3 | "description": "An immutable API on top of PDO to compose and execute SQL statements",
4 | "keywords": ["sql", "pdo", "database", "dop"],
5 | "license": "MIT",
6 | "homepage": "https://github.com/morris/dop",
7 | "authors": [
8 | {
9 | "name": "Morris Brodersen",
10 | "homepage": "https://morrisbrodersen.de"
11 | }
12 | ],
13 | "autoload": {
14 | "psr-4": {
15 | "Dop\\": "src/Dop"
16 | }
17 | },
18 | "require": {
19 | "php": ">=5.3.0"
20 | },
21 | "require-dev": {
22 | "phpunit/phpunit": "^8",
23 | "friendsofphp/php-cs-fixer": "^2.14"
24 | },
25 | "scripts": {
26 | "format": "php vendor/friendsofphp/php-cs-fixer/php-cs-fixer --config=.php_cs.dist fix src tests api.php",
27 | "api": "php api.php > API.md",
28 | "test": "vendor/bin/phpunit",
29 | "cover": "vendor/bin/phpunit --coverage-html coverage"
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: php
2 | php:
3 | - "7.2"
4 |
5 | services:
6 | - mysql
7 | - postgresql
8 |
9 | env:
10 | global:
11 | - CC_TEST_REPORTER_ID=7a569e0aea4669fb8d4e581314bb1f934116f6bc8db7e4f0bdc777f0016a46d4
12 | matrix:
13 | - BOOTSTRAP=sqlite
14 | - BOOTSTRAP=mysql
15 | - BOOTSTRAP=postgresql
16 |
17 | before_install:
18 | - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter
19 | - chmod +x ./cc-test-reporter
20 |
21 | before_script:
22 | - composer self-update
23 | - composer install --prefer-dist
24 | - mysql -e 'create database test;'
25 | - psql -c 'create database test;' -U postgres
26 | - ./cc-test-reporter before-build
27 |
28 | script:
29 | - vendor/bin/phpunit --bootstrap=tests/$BOOTSTRAP.php --coverage-clover=build/logs/clover.xml tests
30 |
31 | after_script:
32 | - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT
33 |
--------------------------------------------------------------------------------
/api.php:
--------------------------------------------------------------------------------
1 |
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/tests/ResultTest.php:
--------------------------------------------------------------------------------
1 | conn;
8 |
9 | $posts = $conn->query('post')->select('id')->exec();
10 |
11 | $this->assertEquals(array( 'id' => '11' ), $posts->fetch());
12 | $this->assertEquals(array( 'id' => '12' ), $posts->fetch());
13 | $this->assertEquals(array( 'id' => '13' ), $posts->fetch());
14 | $this->assertEquals(null, $posts->fetch());
15 | $this->assertEquals(null, $posts->fetch());
16 | $this->assertEquals(
17 | null,
18 | $conn->insertBatch('post', array())->exec()->fetch()
19 | );
20 | $this->assertEquals(
21 | array(),
22 | $conn->insertBatch('post', array())->exec()->fetchAll()
23 | );
24 | }
25 |
26 | public function testClose()
27 | {
28 | $conn = $this->conn;
29 | $posts = $conn->query('post')->exec();
30 | $posts->fetch();
31 | $posts->close();
32 | $posts->close();
33 |
34 | $this->assertTrue(true);
35 | }
36 |
37 | public function testAffected()
38 | {
39 | $conn = $this->conn;
40 |
41 | $this->assertEquals(
42 | 0,
43 | $conn->insertBatch('post', array())->exec()->affected()
44 | );
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # DOP
2 |
3 | [](https://travis-ci.org/morris/dop)
4 | [](https://codeclimate.com/github/morris/dop/coverage)
5 |
6 | DOP is an immutable API on top of [PDO](http://php.net/manual/en/book.pdo.php)
7 | to compose and execute SQL statements.
8 |
9 | - Extended parameters (`::param` and `??`) allow binding to arbitrary values like arrays, `null` and SQL fragments.
10 | - Provides helpers for writing common queries, e.g. selects, inserts, updates, deletes.
11 | - Tested with **SQLite, PostgreSQL, and MySQL.**
12 |
13 | ## Installation
14 |
15 | DOP requires PHP >= 5.3.0 and PDO.
16 | Install via [composer](https://getcomposer.org/):
17 |
18 | ```sh
19 | composer require morris/dop
20 | ```
21 |
22 | ## Usage
23 |
24 | ```php
25 | // Connect to a database
26 | $pdo = new PDO('sqlite:blog.sqlite3');
27 | $dop = new Dop\Connection($pdo);
28 |
29 | // Find posts by author IDs using DOP parametrization
30 | $authorIds = [1, 2, 3];
31 | $orderByTitle = $dop('ORDER BY title ASC');
32 | $posts = $dop(
33 | 'SELECT * FROM post WHERE author_id IN (??) ??',
34 | [$authorIds, $orderByTitle]
35 | )->fetchAll();
36 |
37 | // Find published posts using DOP helpers for common queries
38 | $posts = $dop->query('post')->where('is_published = ?', [1])->fetchAll();
39 |
40 | // Get categorizations of posts using DOP's map function
41 | $categorizations = $dop(
42 | 'SELECT * FROM categorization WHERE post_id IN (??)',
43 | [$dop->map($posts, 'id')]
44 | )->fetchAll();
45 |
46 | // Find posts with more than 3 categorizations using a sub-query as a parameter
47 | $catCount = $dop('SELECT COUNT(*) FROM categorization WHERE post_id = post.id');
48 | $posts = $dop(
49 | 'SELECT * FROM post WHERE (::catCount) >= 3',
50 | ['catCount' => $catCount]
51 | )->fetchAll();
52 | ```
53 |
54 | Internally, `??` and `::named` parameters are resolved before statement preparation.
55 | Note that due to the current implementation using regular expressions,
56 | you should *never* use quoted strings directly. Always use bound parameters.
57 |
58 | ## Reference
59 |
60 | See [API.md](API.md) for a complete API reference.
61 |
62 | ## Contributors
63 |
64 | - [jayaddison](https://github.com/jayaddison)
65 |
66 | Thanks!
67 |
--------------------------------------------------------------------------------
/src/Dop/Result.php:
--------------------------------------------------------------------------------
1 | statement = $statement->resolve();
23 | }
24 |
25 | /**
26 | * Execute the prepared statement (again).
27 | *
28 | * @param array $params
29 | * @return $this
30 | */
31 | public function exec($params = array())
32 | {
33 | $pdoStatement = $this->pdoStatement();
34 |
35 | if (!$pdoStatement) {
36 | return $this;
37 | }
38 |
39 | $statement = $this->statement->bind($params);
40 |
41 | $this->conn()->execCallback($statement, function () use ($pdoStatement, $statement) {
42 | $pdoStatement->execute($statement->params());
43 | });
44 |
45 | return $this;
46 | }
47 |
48 | /**
49 | * Fetch next row.
50 | *
51 | * @param int $offset Offset in rows
52 | * @param int $orientation One of the PDO::FETCH_ORI_* constants
53 | * @return array|null
54 | */
55 | public function fetch($offset = 0, $orientation = null)
56 | {
57 | $pdoStatement = $this->pdoStatement();
58 |
59 | if (!$pdoStatement) {
60 | return null;
61 | }
62 |
63 | $row = $pdoStatement->fetch(
64 | \PDO::FETCH_ASSOC,
65 | isset($orientation) ? $orientation : \PDO::FETCH_ORI_NEXT,
66 | $offset
67 | );
68 |
69 | return $row ? $row : null;
70 | }
71 |
72 | /**
73 | * Fetch all rows.
74 | *
75 | * @return array
76 | */
77 | public function fetchAll()
78 | {
79 | $pdoStatement = $this->pdoStatement();
80 |
81 | if (!$pdoStatement) {
82 | return array();
83 | }
84 |
85 | return $pdoStatement->fetchAll(\PDO::FETCH_ASSOC);
86 | }
87 |
88 | /**
89 | * Close the cursor in this result, if any.
90 | *
91 | * @return $this
92 | */
93 | public function close()
94 | {
95 | $pdoStatement = $this->pdoStatement();
96 |
97 | if ($pdoStatement) {
98 | $pdoStatement->closeCursor();
99 | }
100 |
101 | return $this;
102 | }
103 |
104 | /**
105 | * Return number of affected rows.
106 | *
107 | * @return int
108 | */
109 | public function affected()
110 | {
111 | $pdoStatement = $this->pdoStatement();
112 |
113 | if ($pdoStatement) {
114 | return $pdoStatement->rowCount();
115 | }
116 |
117 | return 0;
118 | }
119 |
120 | /**
121 | * Get this result's connection.
122 | *
123 | * @return Connection
124 | */
125 | public function conn()
126 | {
127 | return $this->statement->conn();
128 | }
129 |
130 | /**
131 | * Get this result's statement.
132 | *
133 | * @return Fragment
134 | */
135 | public function statement()
136 | {
137 | return $this->statement;
138 | }
139 |
140 | /**
141 | * @return \PDOStatement
142 | */
143 | public function pdoStatement()
144 | {
145 | if ($this->pdoStatement) {
146 | return $this->pdoStatement;
147 | }
148 |
149 | $conn = $this->conn();
150 | $statement = $this->statement->toString();
151 |
152 | if ($statement !== $conn::EMPTY_STATEMENT) {
153 | $this->pdoStatement = $conn->pdo()->prepare($statement);
154 | }
155 |
156 | return $this->pdoStatement;
157 | }
158 |
159 | //
160 |
161 | /**
162 | * @internal
163 | */
164 | public function current()
165 | {
166 | return $this->current;
167 | }
168 |
169 | /**
170 | * @internal
171 | */
172 | public function key()
173 | {
174 | return $this->key;
175 | }
176 |
177 | /**
178 | * @internal
179 | */
180 | public function next()
181 | {
182 | $this->current = $this->fetch();
183 | ++$this->key;
184 | }
185 |
186 | /**
187 | * @internal
188 | */
189 | public function rewind()
190 | {
191 | $this->current = $this->fetch();
192 | $this->key = 0;
193 | }
194 |
195 | /**
196 | * @internal
197 | */
198 | public function valid()
199 | {
200 | return $this->current;
201 | }
202 |
203 | //
204 |
205 | /** @var Fragment */
206 | protected $statement;
207 |
208 | /** @var \PDOStatement */
209 | protected $pdoStatement;
210 |
211 | /** @var array */
212 | protected $current;
213 |
214 | /** @var int */
215 | protected $key;
216 | }
217 |
--------------------------------------------------------------------------------
/tests/BaseTest.php:
--------------------------------------------------------------------------------
1 | setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
10 |
11 | $this->resetSchema();
12 | $this->resetData();
13 |
14 | $this->statements = array();
15 | $this->params = array();
16 | $this->conn = new TestConnection(self::$pdo, $this);
17 | }
18 |
19 | public function beforeExec($statement)
20 | {
21 | $sql = $this->str($statement);
22 |
23 | if (strtoupper(substr($sql, 0, 6)) !== 'SELECT') {
24 | self::$dirtyData = true;
25 | }
26 |
27 | $this->statements[] = $sql;
28 | $this->params[] = $statement->resolve()->params();
29 | }
30 |
31 | public function tearDown(): void
32 | {
33 |
34 | // always roll back active transactions
35 | $active = false;
36 |
37 | try {
38 | self::$pdo->rollBack();
39 | $active = true;
40 | } catch (\Exception $ex) {
41 | // ignore
42 | }
43 |
44 | if ($active) {
45 | throw new Exception('There was an active transaction');
46 | }
47 | }
48 |
49 | public function testDummy()
50 | {
51 | $this->assertTrue(true);
52 | }
53 |
54 | public function str($mixed)
55 | {
56 | return str_replace('"', '`', trim((string) $mixed));
57 | }
58 |
59 | //
60 |
61 | public function resetSchema()
62 | {
63 | if (!self::$dirtySchema) {
64 | return;
65 | }
66 |
67 | self::$pdo->beginTransaction();
68 |
69 | if ($this->driver() === 'sqlite') {
70 | $p = "INTEGER PRIMARY KEY AUTOINCREMENT";
71 | }
72 |
73 | if ($this->driver() === 'mysql') {
74 | $p = "INTEGER PRIMARY KEY AUTO_INCREMENT";
75 | }
76 |
77 | if ($this->driver() === 'pgsql') {
78 | $p = "SERIAL PRIMARY KEY";
79 | }
80 |
81 | $this->exec("DROP TABLE IF EXISTS person");
82 |
83 | $this->exec("CREATE TABLE person (
84 | id $p,
85 | name varchar(30) NOT NULL
86 | )");
87 |
88 | $this->exec("DROP TABLE IF EXISTS post");
89 |
90 | $this->exec("CREATE TABLE post (
91 | id $p,
92 | author_id INTEGER DEFAULT NULL,
93 | editor_id INTEGER DEFAULT NULL,
94 | is_published INTEGER DEFAULT 0,
95 | date_published VARCHAR(30) DEFAULT NULL,
96 | title VARCHAR(30) NOT NULL
97 | )");
98 |
99 | $this->exec("DROP TABLE IF EXISTS category");
100 |
101 | $this->exec("CREATE TABLE category (
102 | id $p,
103 | title varchar(30) NOT NULL
104 | )");
105 |
106 | $this->exec("DROP TABLE IF EXISTS categorization");
107 |
108 | $this->exec("CREATE TABLE categorization (
109 | category_id INTEGER NOT NULL,
110 | post_id INTEGER NOT NULL
111 | )");
112 |
113 | $this->exec("DROP TABLE IF EXISTS dummy");
114 |
115 | $this->exec("CREATE TABLE dummy (
116 | id $p,
117 | test DOUBLE PRECISION,
118 | name VARCHAR(30)
119 | )");
120 |
121 | self::$pdo->commit();
122 | self::$dirtySchema = false;
123 | self::$dirtyData = true;
124 | }
125 |
126 | public function resetData()
127 | {
128 | if (!self::$dirtyData) {
129 | return;
130 | }
131 |
132 | self::$pdo->beginTransaction();
133 |
134 | // sequences
135 |
136 | if ($this->driver() === 'sqlite') {
137 | $this->exec("DELETE FROM sqlite_sequence WHERE name='person'");
138 | $this->exec("DELETE FROM sqlite_sequence WHERE name='post'");
139 | $this->exec("DELETE FROM sqlite_sequence WHERE name='category'");
140 | $this->exec("DELETE FROM sqlite_sequence WHERE name='dummy'");
141 | }
142 |
143 | if ($this->driver() === 'mysql') {
144 | $this->exec("ALTER TABLE person AUTO_INCREMENT = 1");
145 | $this->exec("ALTER TABLE post AUTO_INCREMENT = 1");
146 | $this->exec("ALTER TABLE category AUTO_INCREMENT = 1");
147 | $this->exec("ALTER TABLE dummy AUTO_INCREMENT = 1");
148 | }
149 |
150 | if ($this->driver() === 'pgsql') {
151 | $this->exec("SELECT setval('person_id_seq', 3)");
152 | $this->exec("SELECT setval('post_id_seq', 13)");
153 | $this->exec("SELECT setval('category_id_seq', 23)");
154 | $this->exec("SELECT setval('dummy_id_seq', 1, false)");
155 | }
156 |
157 | // data
158 |
159 | // persons
160 |
161 | $this->exec("DELETE FROM person");
162 |
163 | $this->exec("INSERT INTO person (id, name) VALUES (1, 'Writer')");
164 | $this->exec("INSERT INTO person (id, name) VALUES (2, 'Editor')");
165 | $this->exec("INSERT INTO person (id, name) VALUES (3, 'Chief Editor')");
166 |
167 | // posts
168 |
169 | $this->exec("DELETE FROM post");
170 |
171 | $this->exec("INSERT INTO post
172 | (id, title, date_published, is_published, author_id, editor_id)
173 | VALUES (11, 'Championship won', '2014-09-18', 1, 1, NULL)");
174 | $this->exec("INSERT INTO post
175 | (id, title, date_published, is_published, author_id, editor_id)
176 | VALUES (12, 'Foo released', '2014-09-15', 1, 1, 2)");
177 | $this->exec("INSERT INTO post
178 | (id, title, date_published, is_published, author_id, editor_id)
179 | VALUES (13, 'Bar released', '2014-09-21', 0, 2, 3)");
180 |
181 | // categories
182 |
183 | $this->exec("DELETE FROM category");
184 |
185 | $this->exec("INSERT INTO category (id, title) VALUES (21, 'Tech')");
186 | $this->exec("INSERT INTO category (id, title) VALUES (22, 'Sports')");
187 | $this->exec("INSERT INTO category (id, title) VALUES (23, 'Basketball')");
188 |
189 | // categorization
190 |
191 | $this->exec("DELETE FROM categorization");
192 |
193 | $this->exec("INSERT INTO categorization (category_id, post_id) VALUES (22, 11)");
194 | $this->exec("INSERT INTO categorization (category_id, post_id) VALUES (23, 11)");
195 | $this->exec("INSERT INTO categorization (category_id, post_id) VALUES (21, 12)");
196 | $this->exec("INSERT INTO categorization (category_id, post_id) VALUES (21, 13)");
197 |
198 | // dummy
199 |
200 | $this->exec("DELETE FROM dummy");
201 |
202 | self::$pdo->commit();
203 | self::$dirtyData = false;
204 | }
205 |
206 | public function exec($s)
207 | {
208 | return self::$pdo->exec($s);
209 | }
210 |
211 | public function driver()
212 | {
213 | return self::$pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
214 | }
215 |
216 | protected $statements = array();
217 | protected $params = array();
218 |
219 | public static $pdo;
220 | protected static $dirtySchema = true;
221 | protected static $dirtyData = true;
222 | }
223 |
--------------------------------------------------------------------------------
/tests/FragmentTest.php:
--------------------------------------------------------------------------------
1 | conn;
8 |
9 | $s = $conn('SELECT * FROM post, ::tables WHERE ::conds OR foo=:bar OR x in (::lol) :lol', array(
10 | 'tables' => $conn->ident(array('a', 'b')),
11 | 'conds' => $conn->where(array('foo' => 'bar', 'x' => null)),
12 | 'lol' => array(1, 2, 3)
13 | ));
14 |
15 | $this->assertEquals(
16 | "SELECT * FROM post, `a`, `b` WHERE (`foo` = 'bar') AND (`x` IS NULL) OR foo=:bar OR x in ('1', '2', '3') :lol",
17 | str_replace('"', '`', (string) $s)
18 | );
19 |
20 | $this->assertEquals(
21 | array(array(1, 2, 3)),
22 | array_values($s->resolve()->params())
23 | );
24 | }
25 |
26 | public function testDoubleQuestionMark()
27 | {
28 | $conn = $this->conn;
29 |
30 | $s = $conn('SELECT * FROM post WHERE ?? > ??', array($conn('dt'), $conn('NOW()')));
31 |
32 | $this->assertEquals(
33 | "SELECT * FROM post WHERE dt > NOW()",
34 | (string) $s
35 | );
36 | }
37 |
38 | public function testUnresolved()
39 | {
40 | $this->expectException(\Dop\Exception::class);
41 | $this->expectExceptionMessage('Unresolved parameter fields');
42 |
43 | $conn = $this->conn;
44 | $conn('SELECT ::fields FROM ::post')->resolve();
45 | }
46 |
47 | public function testUnresolvedString()
48 | {
49 | $conn = $this->conn;
50 | $this->assertEquals(
51 | 'Unresolved parameter 0',
52 | (string) $conn('SELECT ?? FROM ::post')
53 | );
54 | }
55 |
56 | public function testInvoke()
57 | {
58 | $conn = $this->conn;
59 | $posts = $conn('SELECT * FROM post');
60 | $this->assertEquals(3, count($posts()->fetchAll()));
61 | }
62 |
63 | public function testWhere()
64 | {
65 | $conn = $this->conn;
66 |
67 | $conn->query('dummy')->where('test', null)->fetch();
68 | $conn->query('dummy')->where('test', 31)->fetch();
69 | $conn->query('dummy')->where('test', array(1, 2, 3))->fetch();
70 | $conn->query('dummy')->where(array('test' => 31, 'id' => 1))->fetch();
71 | $conn->query('dummy')->where('test = 31')->fetch();
72 | $conn->query('dummy')->where('test = ?', array(31))->fetch();
73 | $conn->query('dummy')->where('test = ?', array(32))->fetch();
74 | $conn->query('dummy')->where('test = :param', array('param' => 31))->fetch();
75 | $conn->query('dummy')
76 | ->where('test < :a', array('a' => 31))
77 | ->where('test > :b', array('b' => 0))
78 | ->fetch();
79 | $conn->query('dummy')
80 | ->where('test = 1')
81 | ->where('test < 0 OR test > 999')
82 | ->fetch();
83 |
84 | $this->assertEquals(array(
85 | "SELECT * FROM `dummy` WHERE `test` IS NULL",
86 | "SELECT * FROM `dummy` WHERE `test` = '31'",
87 | "SELECT * FROM `dummy` WHERE `test` IN ('1', '2', '3')",
88 | "SELECT * FROM `dummy` WHERE (`test` = '31') AND (`id` = '1')",
89 | "SELECT * FROM `dummy` WHERE test = 31",
90 | "SELECT * FROM `dummy` WHERE test = ?",
91 | "SELECT * FROM `dummy` WHERE test = ?",
92 | "SELECT * FROM `dummy` WHERE test = :param",
93 | "SELECT * FROM `dummy` WHERE (test < :a) AND (test > :b)",
94 | "SELECT * FROM `dummy` WHERE (test = 1) AND (test < 0 OR test > 999)"
95 | ), $this->statements);
96 |
97 | $this->assertEquals(array(
98 | array(),
99 | array(),
100 | array(),
101 | array(),
102 | array(),
103 | array(31),
104 | array(32),
105 | array('param' => 31),
106 | array('a' => 31, 'b' => 0),
107 | array()
108 | ), $this->params);
109 | }
110 |
111 | public function testWhereNot()
112 | {
113 | $conn = $this->conn;
114 |
115 | $conn->query('dummy')->whereNot('test', null)->fetch();
116 | $conn->query('dummy')->whereNot('test', 31)->fetch();
117 | $conn->query('dummy')->whereNot('test', array(1, 2, 3))->fetch();
118 | $conn->query('dummy')->whereNot(array('test' => 31, 'id' => 1))->fetch();
119 | $conn->query('dummy')
120 | ->whereNot('test', null)
121 | ->whereNot('test', 31)
122 | ->fetch();
123 |
124 | $this->assertEquals(array(
125 | "SELECT * FROM `dummy` WHERE `test` IS NOT NULL",
126 | "SELECT * FROM `dummy` WHERE `test` != '31'",
127 | "SELECT * FROM `dummy` WHERE `test` NOT IN ('1', '2', '3')",
128 | "SELECT * FROM `dummy` WHERE (`test` != '31') AND `id` != '1'",
129 | "SELECT * FROM `dummy` WHERE (`test` IS NOT NULL) AND `test` != '31'"
130 | ), $this->statements);
131 |
132 | $this->assertEquals(array(
133 | array(),
134 | array(),
135 | array(),
136 | array(),
137 | array()
138 | ), $this->params);
139 | }
140 |
141 | public function testOrderBy()
142 | {
143 | $conn = $this->conn;
144 |
145 | $conn->query('dummy')->orderBy('id', 'DESC')->orderBy('test')->fetch();
146 |
147 | $this->assertEquals(array(
148 | "SELECT * FROM `dummy` WHERE 1=1 ORDER BY `id` DESC, `test` ASC",
149 | ), $this->statements);
150 | }
151 |
152 | public function testInvalidOrderBy()
153 | {
154 | $this->expectException(\Dop\Exception::class);
155 | $this->expectExceptionMessage('Invalid ORDER BY direction: DESK');
156 |
157 | $conn = $this->conn;
158 | $conn->query('dummy')->orderBy('id', 'DESK');
159 | }
160 |
161 | public function testLimit()
162 | {
163 | $conn = $this->conn;
164 |
165 | $conn->query('dummy')->limit(3)->fetch();
166 | $conn->query('dummy')->limit(3, 10)->fetch();
167 | $conn->query('dummy')->limit()->fetch();
168 |
169 | $this->assertEquals(array(
170 | "SELECT * FROM `dummy` WHERE 1=1 LIMIT 3",
171 | "SELECT * FROM `dummy` WHERE 1=1 LIMIT 3 OFFSET 10",
172 | "SELECT * FROM `dummy` WHERE 1=1"
173 | ), $this->statements);
174 | }
175 |
176 | public function testPaged()
177 | {
178 | $conn = $this->conn;
179 |
180 | $conn->query('dummy')->paged(10, 1)->fetch();
181 | $conn->query('dummy')->paged(10, 3)->fetch();
182 |
183 | $this->assertEquals(array(
184 | "SELECT * FROM `dummy` WHERE 1=1 LIMIT 10 OFFSET 0",
185 | "SELECT * FROM `dummy` WHERE 1=1 LIMIT 10 OFFSET 20",
186 | ), $this->statements);
187 | }
188 |
189 | public function testSelect()
190 | {
191 | $conn = $this->conn;
192 |
193 | $conn->query('dummy')->select('test')->fetch();
194 | $conn->query('dummy')->select('test', 'id')->fetch();
195 | $conn->query('dummy')->select('test')->select('id')->fetch();
196 |
197 | $this->assertEquals(array(
198 | "SELECT `test` FROM `dummy` WHERE 1=1",
199 | "SELECT `test`, `id` FROM `dummy` WHERE 1=1",
200 | "SELECT `test`, `id` FROM `dummy` WHERE 1=1"
201 | ), $this->statements);
202 | }
203 |
204 | public function testFirst()
205 | {
206 | $conn = $this->conn;
207 | $first = $conn->query('post')->fetch();
208 | $this->assertEquals('Championship won', $first[ 'title' ]);
209 | }
210 |
211 | public function testAll()
212 | {
213 | $conn = $this->conn;
214 | $this->assertEquals(3, count($conn->query('post')->fetchAll()));
215 | }
216 |
217 | public function testAffected()
218 | {
219 | $conn = $this->conn;
220 |
221 | $this->assertEquals(3, count($conn->query('post')->fetchAll()));
222 |
223 | $a = $conn('UPDATE post SET title = ?', array('fooz'))->exec()->affected();
224 | $this->assertEquals(3, $a);
225 |
226 | $a = $conn('UPDATE post SET title = ? WHERE 0=1', array('test'))->exec()->affected();
227 | $this->assertEquals(0, $a);
228 | }
229 |
230 | public function testIterate()
231 | {
232 | $conn = $this->conn;
233 |
234 | $ids = array();
235 | foreach ($conn->query('post') as $i => $post) {
236 | $ids[ $i ] = $post[ 'id' ];
237 | }
238 |
239 | $this->assertEquals(array('11', '12', '13'), $ids);
240 | }
241 | }
242 |
--------------------------------------------------------------------------------
/src/Dop/Fragment.php:
--------------------------------------------------------------------------------
1 | conn = $conn;
23 | $this->sql = $sql;
24 | $this->params = $params;
25 | }
26 |
27 | /**
28 | * Return a new fragment with the given parameter(s).
29 | *
30 | * @param array|string|int $params Array of key-value parameters or parameter name
31 | * @param mixed $value If $params is a parameter name, bind to this value
32 | * @return Fragment
33 | */
34 | public function bind($params, $value = null)
35 | {
36 | if (empty($params) && $params !== 0) {
37 | return $this;
38 | }
39 |
40 | if (!is_array($params)) {
41 | return $this->bind(array($params => $value));
42 | }
43 |
44 | $clone = clone $this;
45 |
46 | foreach ($params as $key => $value) {
47 | $clone->params[$key] = $value;
48 | }
49 |
50 | return $clone;
51 | }
52 |
53 | /**
54 | * @see Fragment::exec
55 | */
56 | public function __invoke($params = null)
57 | {
58 | return $this->exec($params);
59 | }
60 |
61 | /**
62 | * Execute statement and return result.
63 | *
64 | * @param array $params
65 | * @return Result The prepared and executed result
66 | */
67 | public function exec($params = array())
68 | {
69 | return $this->prepare($params)->exec();
70 | }
71 |
72 | /**
73 | * Return prepared statement from this fragment.
74 | *
75 | * @param array $params
76 | * @return Result The prepared result
77 | */
78 | public function prepare($params = array())
79 | {
80 | return new Result($this->bind($params));
81 | }
82 |
83 | //
84 |
85 | /**
86 | * Execute, fetch and return first row, if any.
87 | *
88 | * @param int $offset Offset to skip
89 | * @return array|null
90 | */
91 | public function fetch($offset = 0)
92 | {
93 | return $this->exec()->fetch($offset);
94 | }
95 |
96 | /**
97 | * Execute, fetch and return all rows.
98 | *
99 | * @return array
100 | */
101 | public function fetchAll()
102 | {
103 | return $this->exec()->fetchAll();
104 | }
105 |
106 | //
107 |
108 | /**
109 | * Return new fragment with additional SELECT field or expression.
110 | *
111 | * @param string|Fragment $expr
112 | * @return Fragment
113 | */
114 | public function select($expr)
115 | {
116 | $before = (string) @$this->params['select'];
117 | if (!$before || (string) $before === '*') {
118 | $before = '';
119 | } else {
120 | $before .= ', ';
121 | }
122 |
123 | return $this->bind(array(
124 | 'select' => $this->conn->raw(
125 | $before . $this->conn->ident(func_get_args())
126 | )
127 | ));
128 | }
129 |
130 | /**
131 | * Return new fragment with additional WHERE condition
132 | * (multiple are combined with AND).
133 | *
134 | * @param string|array $condition
135 | * @param mixed|array $params
136 | * @return Fragment
137 | */
138 | public function where($condition, $params = array())
139 | {
140 | return $this->bind(array(
141 | 'where' => $this->conn->where($condition, $params, @$this->params['where'])
142 | ));
143 | }
144 |
145 | /**
146 | * Return new fragment with additional "$column is not $value" condition
147 | * (multiple are combined with AND).
148 | *
149 | * @param string|array $column
150 | * @param mixed $value
151 | * @return Fragment
152 | */
153 | public function whereNot($key, $value = null)
154 | {
155 | return $this->bind(array(
156 | 'where' => $this->conn->whereNot($key, $value, @$this->params['where'])
157 | ));
158 | }
159 |
160 | /**
161 | * Return new fragment with additional ORDER BY column and direction.
162 | *
163 | * @param string $column
164 | * @param string $direction
165 | * @return Fragment
166 | */
167 | public function orderBy($column, $direction = "ASC")
168 | {
169 | return $this->bind(array(
170 | 'orderBy' => $this->conn->orderBy($column, $direction, @$this->params['orderBy'])
171 | ));
172 | }
173 |
174 | /**
175 | * Return new fragment with result limit and optionally an offset.
176 | *
177 | * @param int|null $count
178 | * @param int|null $offset
179 | * @return Fragment
180 | */
181 | public function limit($count = null, $offset = null)
182 | {
183 | return $this->bind(array(
184 | 'limit' => $this->conn->limit($count, $offset)
185 | ));
186 | }
187 |
188 | /**
189 | * Return new fragment with paged limit.
190 | *
191 | * Pages start at 1.
192 | *
193 | * @param int $pageSize
194 | * @param int $page
195 | * @return Fragment
196 | */
197 | public function paged($pageSize, $page)
198 | {
199 | return $this->limit($pageSize, ($page - 1) * $pageSize);
200 | }
201 |
202 | /**
203 | * Get connection.
204 | *
205 | * @return Connection
206 | */
207 | public function conn()
208 | {
209 | return $this->conn;
210 | }
211 |
212 | /**
213 | * Get resolved SQL string of this fragment.
214 | *
215 | * @return string
216 | */
217 | public function toString()
218 | {
219 | return $this->resolve()->sql;
220 | }
221 |
222 | /**
223 | * Get bound parameters.
224 | *
225 | * @return array
226 | */
227 | public function params()
228 | {
229 | return $this->params;
230 | }
231 |
232 | //
233 |
234 | /**
235 | * @see Fragment::toString
236 | */
237 | public function __toString()
238 | {
239 | try {
240 | return $this->toString();
241 | } catch (\Exception $ex) {
242 | return $ex->getMessage();
243 | }
244 | }
245 |
246 | //
247 |
248 | /**
249 | * Execute and return iterable Result.
250 | *
251 | * @return \Iterator
252 | */
253 | public function getIterator()
254 | {
255 | return $this->exec();
256 | }
257 |
258 | //
259 |
260 | /**
261 | * Return SQL fragment with all :: and ?? params resolved.
262 | *
263 | * @return Fragment
264 | */
265 | public function resolve()
266 | {
267 | if ($this->resolved) {
268 | return $this->resolved;
269 | }
270 |
271 | static $rx;
272 |
273 | if (!isset($rx)) {
274 | $rx = '(' . implode('|', array(
275 | '(\?\?)', // 1 double question mark
276 | '(\?)', // 2 question mark
277 | '(::[a-zA-Z_$][a-zA-Z0-9_$]*)', // 3 double colon marker
278 | '(:[a-zA-Z_$][a-zA-Z0-9_$]*)' // 4 colon marker
279 | )) . ')s';
280 | }
281 |
282 | $this->resolveParams = array();
283 | $this->resolveOffset = 0;
284 |
285 | $resolved = preg_replace_callback($rx, array($this, 'resolveCallback'), $this->sql);
286 |
287 | $this->resolved = $this->conn->fragment($resolved, $this->resolveParams);
288 | $this->resolved->resolved = $this->resolved;
289 |
290 | $this->resolveParams = $this->resolveOffset = null;
291 |
292 | return $this->resolved;
293 | }
294 |
295 | /**
296 | * @param array $match
297 | * @return string
298 | */
299 | protected function resolveCallback($match)
300 | {
301 | $conn = $this->conn;
302 |
303 | $type = 1;
304 | while (!($string = $match[$type])) {
305 | ++$type;
306 | }
307 |
308 | $replacement = $string;
309 | $key = substr($string, 1);
310 |
311 | switch ($type) {
312 | case 1:
313 | if (array_key_exists($this->resolveOffset, $this->params)) {
314 | $replacement = $conn->value($this->params[$this->resolveOffset]);
315 | } else {
316 | throw new Exception('Unresolved parameter ' . $this->resolveOffset);
317 | }
318 |
319 | ++$this->resolveOffset;
320 | break;
321 | case 2:
322 | if (array_key_exists($this->resolveOffset, $this->params)) {
323 | $this->resolveParams[] = $this->params[$this->resolveOffset];
324 | } else {
325 | $this->resolveParams[] = null;
326 | }
327 |
328 | ++$this->resolveOffset;
329 | break;
330 | case 3:
331 | $key = substr($key, 1);
332 |
333 | if (array_key_exists($key, $this->params)) {
334 | $replacement = $conn->value($this->params[$key]);
335 | } else {
336 | throw new Exception('Unresolved parameter ' . $key);
337 | }
338 |
339 | break;
340 | case 4:
341 | if (array_key_exists($key, $this->params)) {
342 | $this->resolveParams[$key] = $this->params[$key];
343 | }
344 |
345 | break;
346 | }
347 |
348 | // handle fragment insertion
349 | if ($replacement instanceof Fragment) {
350 | $replacement = $replacement->resolve();
351 |
352 | // merge fragment parameters
353 | // numbered params are appended
354 | // named params are merged only if the param does not exist yet
355 | foreach ($replacement->params() as $key => $value) {
356 | if (is_int($key)) {
357 | $this->resolveParams[] = $value;
358 | } elseif (!array_key_exists($key, $this->params)) {
359 | $this->resolveParams[$key] = $value;
360 | }
361 | }
362 |
363 | $replacement = $replacement->toString();
364 | }
365 |
366 | return $replacement;
367 | }
368 |
369 | /**
370 | * Create a raw SQL fragment copy of this fragment.
371 | * The new fragment will not be resolved, i.e. ?? and :: params ignored.
372 | *
373 | * @return Fragment
374 | */
375 | public function raw()
376 | {
377 | $clone = clone $this;
378 | $clone->resolved = $clone;
379 |
380 | return $clone;
381 | }
382 |
383 | /**
384 | * @ignore
385 | */
386 | public function __clone()
387 | {
388 | if ($this->resolved && $this->resolved->sql === $this->sql) {
389 | $this->resolved = $this;
390 | } else {
391 | $this->resolved = null;
392 | }
393 | }
394 |
395 | //
396 |
397 | /** @var Connection */
398 | protected $conn;
399 |
400 | /** @var string */
401 | protected $sql;
402 |
403 | /** @var array */
404 | protected $params;
405 |
406 | /** @var Fragment */
407 | protected $resolved;
408 |
409 | /** @var int */
410 | protected $resolveOffset;
411 |
412 | /** @var array */
413 | protected $resolveParams;
414 | }
415 |
--------------------------------------------------------------------------------
/API.md:
--------------------------------------------------------------------------------
1 | # API
2 |
3 | ```php
4 | conn;
8 | $this->assertEquals($this->driver(), $conn->driver());
9 | }
10 |
11 | public function testTransaction()
12 | {
13 | $conn = $this->conn;
14 | $i = 0;
15 |
16 | $conn->transaction(function () use (&$i) {
17 | ++$i;
18 | });
19 |
20 | $conn->transaction(function () use (&$i) {
21 | ++$i;
22 | });
23 |
24 | $this->assertEquals($i, 2);
25 | }
26 |
27 | public function testTransactionCallable()
28 | {
29 | $this->expectException(\Dop\Exception::class);
30 | $this->expectExceptionMessage('Transaction must be callable');
31 |
32 | $conn = $this->conn;
33 | $conn->transaction('notcallable');
34 | }
35 |
36 | public function testTransactionException()
37 | {
38 | $this->expectException(\Exception::class);
39 | $this->expectExceptionMessage('test');
40 |
41 | $conn = $this->conn;
42 | $conn->transaction(function () {
43 | throw new \Exception('test');
44 | });
45 | }
46 |
47 | public function testTransactionNestedException()
48 | {
49 | $this->expectException(\Exception::class);
50 | $this->expectExceptionMessage('test');
51 |
52 | $conn = $this->conn;
53 | $conn->transaction(function () use ($conn) {
54 | $conn->transaction(function () {
55 | throw new \Exception('test');
56 | });
57 | });
58 | }
59 |
60 | public function testValue()
61 | {
62 | $conn = $this->conn;
63 |
64 | $a = array_map(array($this, 'str'), array(
65 | $conn->value(null),
66 | $conn->value($conn('NULL')),
67 | $conn->value(false),
68 | $conn->value(true),
69 | $conn->value(0),
70 | $conn->value(1),
71 | $conn->value(0.0),
72 | $conn->value(3.1),
73 | $conn->value(0.00003569),
74 | $conn->value('1'),
75 | $conn->value('foo'),
76 | $conn->value(''),
77 | $conn->value($conn()),
78 | $conn->value($conn('BAR')),
79 | ));
80 |
81 | $ex = array(
82 | "NULL",
83 | "NULL",
84 | "'0'",
85 | "'1'",
86 | "'0'",
87 | "'1'",
88 | "'0'",
89 | "'3.1'",
90 | "'3.569E-5'",
91 | "'1'",
92 | "'foo'",
93 | "''",
94 | "",
95 | "BAR",
96 | );
97 |
98 | $this->assertEquals($ex, $a);
99 | }
100 |
101 | public function testIdent()
102 | {
103 | $conn = $this->conn;
104 |
105 | $d = $this->driver() === 'mysql' ? '`' : '"';
106 |
107 | $a = array_map(array($this, 'str'), array(
108 | $conn->ident('foo'),
109 | $conn->ident('foo.bar'),
110 | $conn->ident('foo' . $d . '.bar'),
111 | $conn->ident($conn->ident('foo' . $d . '.bar')),
112 | ));
113 |
114 | $ex = array(
115 | '`foo`',
116 | '`foo.bar`',
117 | '`foo``.bar`',
118 | '`foo``.bar`'
119 | );
120 |
121 | $this->assertEquals($ex, $a);
122 | }
123 |
124 | public function testIdentTooLong()
125 | {
126 | $this->expectException(\Dop\Exception::class);
127 | $this->expectExceptionMessage('Identifier is longer than 64 characters');
128 |
129 | $conn = $this->conn;
130 | $conn->ident(str_repeat('x', 65));
131 | }
132 |
133 | public function testNestedTransactions()
134 | {
135 | $conn = $this->conn;
136 |
137 | $conn->transaction(function ($conn) use (&$i) {
138 | $conn->transaction(function ($conn) use (&$i) {
139 | ++$i;
140 | });
141 | });
142 |
143 | $this->assertEquals($i, 1);
144 | }
145 |
146 | public function testIs()
147 | {
148 | $conn = $this->conn;
149 |
150 | $a = array_map(array($this, 'str'), array(
151 | $conn->is('foo', null),
152 | $conn->is('foo', 0),
153 | $conn->is('foo', 'bar'),
154 | $conn->is('foo', new \DateTime('2015-01-01 01:00:00')),
155 | $conn->is('foo', $conn('BAR')),
156 | $conn->is('foo', array('x', 'y')),
157 | $conn->is('foo', array('x', null)),
158 | $conn->is('foo', array('x')),
159 | $conn->is('foo', array()),
160 | $conn->is('foo', array(null)),
161 | ));
162 |
163 | $ex = array(
164 | "`foo` IS NULL",
165 | "`foo` = '0'",
166 | "`foo` = 'bar'",
167 | "`foo` = '2015-01-01 01:00:00'",
168 | "`foo` = BAR",
169 | "`foo` IN ('x', 'y')",
170 | "`foo` IN ('x') OR `foo` IS NULL",
171 | "`foo` = 'x'",
172 | "0=1",
173 | "`foo` IS NULL",
174 | );
175 |
176 | $this->assertEquals($ex, $a);
177 | }
178 |
179 | public function testIsNot()
180 | {
181 | $conn = $this->conn;
182 |
183 | $a = array_map(array($this, 'str'), array(
184 | $conn->isNot('foo', null),
185 | $conn->isNot('foo', 0),
186 | $conn->isNot('foo', 'bar'),
187 | $conn->isNot('foo', new \DateTime('2015-01-01 01:00:00')),
188 | $conn->isNot('foo', $conn('BAR')),
189 | $conn->isNot('foo', array('x', 'y')),
190 | $conn->isNot('foo', array('x', null)),
191 | $conn->isNot('foo', array('x')),
192 | $conn->isNot('foo', array()),
193 | $conn->isNot('foo', array(null))
194 | ));
195 |
196 | $ex = array(
197 | "`foo` IS NOT NULL",
198 | "`foo` != '0'",
199 | "`foo` != 'bar'",
200 | "`foo` != '2015-01-01 01:00:00'",
201 | "`foo` != BAR",
202 | "`foo` NOT IN ('x', 'y')",
203 | "`foo` NOT IN ('x') AND `foo` IS NOT NULL",
204 | "`foo` != 'x'",
205 | "1=1",
206 | "`foo` IS NOT NULL"
207 | );
208 |
209 | $this->assertEquals($ex, $a);
210 | }
211 |
212 | public function testInsert()
213 | {
214 | $conn = $this->conn;
215 |
216 | $conn->transaction(function ($conn) {
217 | $conn->insert('dummy', array('id' => 2, 'test' => 42))->exec();
218 |
219 | foreach (
220 | array(
221 | array('id' => 3, 'test' => 1),
222 | array('id' => 4, 'test' => 2),
223 | array('id' => 5, 'test' => 0.00003569)
224 | ) as $row
225 | ) {
226 | $conn->insert('dummy', $row)->exec();
227 | }
228 | });
229 |
230 | $this->assertEquals(array(
231 | "INSERT INTO `dummy` (`id`, `test`) VALUES ('2', '42')",
232 | "INSERT INTO `dummy` (`id`, `test`) VALUES ('3', '1')",
233 | "INSERT INTO `dummy` (`id`, `test`) VALUES ('4', '2')",
234 | "INSERT INTO `dummy` (`id`, `test`) VALUES ('5', '3.569E-5')"
235 | ), $this->statements);
236 |
237 | $this->assertEquals(array(
238 | array('id' => 2, 'test' => 42),
239 | array('id' => 3, 'test' => 1),
240 | array('id' => 4, 'test' => 2),
241 | array('id' => 5, 'test' => 0.00003569)
242 | ), $conn->query('dummy')->select('id')->select('test')->fetchAll());
243 | }
244 |
245 | public function testInsertPrepared()
246 | {
247 | $conn = $this->conn;
248 | $test = $this;
249 |
250 | $conn->transaction(function ($conn) use ($test) {
251 | $result = $conn->insertPrepared('dummy', array(
252 | array('test' => 1),
253 | array('test' => 2),
254 | array('test' => 3)
255 | ));
256 | $conn->insertPrepared('dummy', array());
257 | $test->assertTrue(intval($conn->lastInsertId('dummy_id_seq')) > 0);
258 | });
259 |
260 | $this->assertEquals(array(
261 | "INSERT INTO `dummy` (`test`) VALUES (?)",
262 | "INSERT INTO `dummy` (`test`) VALUES (?)",
263 | "INSERT INTO `dummy` (`test`) VALUES (?)"
264 | ), $this->statements);
265 |
266 | $this->assertEquals(array(
267 | array(1),
268 | array(2),
269 | array(3)
270 | ), $this->params);
271 |
272 | $this->assertEquals(array(
273 | array('test' => 1),
274 | array('test' => 2),
275 | array('test' => 3)
276 | ), $conn->query('dummy')->select('test')->fetchAll());
277 | }
278 |
279 | public function testInsertBatch()
280 | {
281 | $conn = $this->conn;
282 |
283 | $insert = $conn->insertBatch('dummy', array(
284 | array('test' => 1),
285 | array('test' => 2),
286 | array('test' => 3)
287 | ));
288 |
289 | $this->beforeExec($insert);
290 | $this->assertEquals(
291 | array("INSERT INTO `dummy` (`test`) VALUES ('1'), ('2'), ('3')"),
292 | $this->statements
293 | );
294 |
295 | if ($this->driver() === 'sqlite') {
296 | return;
297 | }
298 |
299 | $insert->exec();
300 |
301 | $this->assertEquals(array(
302 | array('test' => 1),
303 | array('test' => 2),
304 | array('test' => 3)
305 | ), $conn->query('dummy')->select('test')->fetchAll());
306 | }
307 |
308 | public function testInsertBatchDefault()
309 | {
310 | $conn = $this->conn;
311 |
312 | $insert = $conn->insertBatch('dummy', array(
313 | array('test' => 1),
314 | array(),
315 | array('test' => 3)
316 | ));
317 |
318 | $this->beforeExec($insert);
319 | $this->assertEquals(
320 | array("INSERT INTO `dummy` (`test`) VALUES ('1'), (DEFAULT), ('3')"),
321 | $this->statements
322 | );
323 |
324 | if ($this->driver() === 'sqlite') {
325 | return;
326 | }
327 |
328 | $insert->exec();
329 |
330 | $this->assertEquals(array(
331 | array('test' => 1),
332 | array('test' => 0),
333 | array('test' => 3)
334 | ), $conn->query('dummy')->select('test')->fetchAll());
335 | }
336 |
337 | public function testUpdate()
338 | {
339 | $conn = $this->conn;
340 | $self = $this;
341 |
342 | $conn->transaction(function ($conn) use ($self) {
343 | $conn->update('dummy', array())->exec();
344 | $conn->update('dummy', array('test' => 42))->exec();
345 | $conn->update('dummy', array('test' => 42))->where('test', 1)->exec();
346 | $conn->update('dummy', new \ArrayIterator(array('test' => 42)))->where('test', 1)->exec();
347 | });
348 |
349 | $this->assertEquals(array(
350 | "UPDATE `dummy` SET `test` = '42' WHERE 1=1",
351 | "UPDATE `dummy` SET `test` = '42' WHERE `test` = '1'",
352 | "UPDATE `dummy` SET `test` = '42' WHERE `test` = '1'"
353 | ), $this->statements);
354 | }
355 |
356 | public function testDelete()
357 | {
358 | $conn = $this->conn;
359 | $self = $this;
360 |
361 | $conn->transaction(function ($conn) use ($self) {
362 | $conn->delete('dummy')->exec();
363 | $conn->delete('dummy', array('test' => 1))->exec();
364 | });
365 |
366 | $this->assertEquals(array(
367 | "DELETE FROM `dummy` WHERE 1=1",
368 | "DELETE FROM `dummy` WHERE `test` = '1'"
369 | ), $this->statements);
370 | }
371 |
372 | public function testRaw()
373 | {
374 | $conn = $this->conn;
375 |
376 | $raw = "SELECT * FROM dummy WHERE test='::test' AND foo=::test and ?? = ?";
377 | $frag = $conn->raw($raw);
378 |
379 | $this->assertEquals($frag, $frag->resolve());
380 | $this->assertEquals($raw, (string) $frag);
381 | $this->assertEquals($raw, (string) $frag->resolve());
382 |
383 | $frag = $frag->bind(0, 1);
384 |
385 | $this->assertEquals($frag, $frag->resolve());
386 | $this->assertEquals($raw, (string) $frag);
387 | $this->assertEquals($raw, (string) $frag->resolve());
388 | }
389 |
390 | public function testReadme()
391 | {
392 | $dop = $this->conn;
393 |
394 | // Find published posts
395 | $posts = $dop->query('post')->where('is_published = ?', array(1))->fetchAll();
396 |
397 | // Get categorizations
398 | $categorizations = $dop(
399 | 'select * from categorization where post_id in (??)',
400 | array($dop->map($posts, 'id'))
401 | )->fetchAll();
402 |
403 | // Find posts with more than 3 categorizations
404 | $catCount = $dop('select count(*) from categorization where post_id = post.id');
405 | $posts = $dop(
406 | 'select * from post where (::catCount) >= 3',
407 | array('catCount' => $catCount)
408 | )->fetchAll();
409 |
410 | //
411 |
412 | $authorIds = array(1, 2, 3);
413 | $orderByTitle = $dop('order by title asc');
414 | $posts = $dop(
415 | 'select id from post where author_id in (??) ??',
416 | array($authorIds, $orderByTitle)
417 | );
418 |
419 | // use $posts as sub query
420 | $cats = $dop(
421 | 'select * from categorization where post_id in (::posts)',
422 | array('posts' => $posts)
423 | )->exec();
424 |
425 | $this->assertEquals($dop->map($cats, function ($row) {
426 | return $row['category_id'];
427 | }), array('22', '23', '21', '21'));
428 | }
429 |
430 | public function testInjection()
431 | {
432 | $conn = $this->conn;
433 |
434 | $conn->insert('dummy', array(
435 | 'name' => 'hello?'
436 | ))->exec();
437 |
438 | $conn->update('dummy', array(
439 | 'name' => 'hello? ::world'
440 | ))->exec();
441 |
442 | $this->assertEquals(
443 | $conn->query('dummy')
444 | ->where('name', 'hello? ::world')
445 | ->fetch(),
446 | array(
447 | 'id' => 1,
448 | 'test' => null,
449 | 'name' => 'hello? ::world'
450 | )
451 | );
452 |
453 | $conn('::insert', array(
454 | 'insert' => $conn->insert('dummy', array(
455 | 'name' => 'hello?'
456 | ))
457 | ))->exec();
458 |
459 | $this->assertEquals(
460 | $conn->query('dummy')
461 | ->where('name', 'hello?')
462 | ->fetch(),
463 | array(
464 | 'id' => 2,
465 | 'test' => null,
466 | 'name' => 'hello?'
467 | )
468 | );
469 | }
470 |
471 | public function testMap()
472 | {
473 | $conn = $this->conn;
474 |
475 | $this->assertEquals(array(11, 12, 13), $conn->map($conn->query('post'), 'id'));
476 |
477 | $this->assertEquals(array(
478 | array(
479 | 'id' => 11,
480 | 'title' => 'Championship won'
481 | ),
482 | array(
483 | 'id' => 12,
484 | 'title' => 'Foo released'
485 | ),
486 | array(
487 | 'id' => 13,
488 | 'title' => 'Bar released'
489 | )
490 | ), $conn->map($conn->query('post'), array('id', 'title')));
491 |
492 | $this->assertEquals(array(
493 | 'Championship won: 11',
494 | 'Foo released: 12',
495 | 'Bar released: 13'
496 | ), $conn->map($conn->query('post'), function ($row) {
497 | return $row['title'] . ': ' . $row['id'];
498 | }));
499 | }
500 |
501 | public function testFilter()
502 | {
503 | $conn = $this->conn;
504 |
505 | $this->assertEquals(array(
506 | array(
507 | 'id' => '11',
508 | 'title' => 'Championship won',
509 | 'is_published' => '1',
510 | 'date_published' => '2014-09-18',
511 | 'author_id' => '1',
512 | 'editor_id' => null
513 | ),
514 | ), $conn->filter($conn->query('post'), 'id', 11));
515 |
516 | $this->assertEquals(array(), $conn->filter($conn->query('post'), 'id', 99));
517 |
518 | $this->assertEquals(array(
519 | array(
520 | 'id' => '11',
521 | 'title' => 'Championship won',
522 | 'is_published' => '1',
523 | 'date_published' => '2014-09-18',
524 | 'author_id' => '1',
525 | 'editor_id' => null
526 | ),
527 | ), $conn->filter($conn->query('post')->exec(), array('id' => 11, 'title' => 'Championship won')));
528 |
529 | $this->assertEquals(array(), $conn->filter(
530 | $conn->query('post'),
531 | array('id' => 99, 'title' => 'Championship won')
532 | ));
533 |
534 | $this->assertEquals(3, count($conn->filter($conn->query('post'), array())));
535 |
536 | $this->assertEquals(2, count($conn->filter($conn->query('post'), function ($row) {
537 | return $row['id'] > 11;
538 | })));
539 |
540 | $notFirst = $conn->filter($conn->query('post'), function ($row) {
541 | return $row['id'] > 11;
542 | });
543 |
544 | $this->assertTrue(isset($notFirst[0]));
545 | $this->assertTrue(isset($notFirst[1]));
546 | $this->assertFalse(isset($notFirst[3]));
547 | }
548 |
549 | public function testInvalidLimitCount()
550 | {
551 | $conn = $this->conn;
552 |
553 | $this->expectException(\Exception::class);
554 | $this->expectExceptionMessage('Invalid LIMIT count: -1');
555 |
556 | $conn->query('post')->limit(-1)->exec();
557 | }
558 |
559 | public function testInvalidLimitOffset()
560 | {
561 | $conn = $this->conn;
562 |
563 | $this->expectException(\Exception::class);
564 | $this->expectExceptionMessage('Invalid LIMIT offset: -1');
565 |
566 | $conn->query('post')->limit(1, -1)->exec();
567 | }
568 |
569 | public function testFragmentFragment()
570 | {
571 | $conn = $this->conn;
572 |
573 | $this->assertEquals('select 0=\'1\'', $this->str($conn($conn('select 0=??'), array(1))));
574 | }
575 |
576 | public function testColumns()
577 | {
578 | $this->assertEquals(array(), $this->conn->columns(null));
579 | }
580 | }
581 |
--------------------------------------------------------------------------------
/src/Dop/Connection.php:
--------------------------------------------------------------------------------
1 | setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
21 | $this->pdo = $pdo;
22 |
23 | $defaultIdentDelimiter = $this->driver() === 'mysql' ? '`' : '"';
24 | $this->identDelimiter = isset($options['identDelimiter']) ?
25 | $options['identDelimiter'] : $defaultIdentDelimiter;
26 | }
27 |
28 | /**
29 | * Returns a basic SELECT query for table $name.
30 | *
31 | * SELECT [::select | *] FROM ::table WHERE [::where] [::orderBy] [::limit]
32 | *
33 | * @param string $name
34 | * @return Fragment
35 | */
36 | public function query($table)
37 | {
38 | return $this('SELECT ::select FROM ::table WHERE ::where ::orderBy ::limit', array(
39 | 'select' => $this('*'),
40 | 'table' => $this->table($table),
41 | 'where' => $this->where(),
42 | 'orderBy' => $this(),
43 | 'limit' => $this()
44 | ));
45 | }
46 |
47 | /**
48 | * Build an insert statement to insert a single row.
49 | *
50 | * INSERT INTO ::table (::columns) VALUES ::values
51 | *
52 | * @param string $table
53 | * @param array|\Traversable $row
54 | * @return Fragment
55 | */
56 | public function insert($table, $row)
57 | {
58 | return $this->insertBatch($table, array($row));
59 | }
60 |
61 | /**
62 | * Build single batch statement to insert multiple rows.
63 | *
64 | * Create a single statement with multiple value lists.
65 | * Supports SQL fragment parameters, but not supported by all drivers.
66 | *
67 | * INSERT INTO ::table (::columns) VALUES ::values
68 | *
69 | * @param string $table
70 | * @param array|\Traversable $rows
71 | * @return Fragment
72 | */
73 | public function insertBatch($table, $rows)
74 | {
75 | if ($this->empt($rows)) {
76 | return $this(self::EMPTY_STATEMENT);
77 | }
78 |
79 | $columns = $this->columns($rows);
80 |
81 | $lists = array();
82 |
83 | foreach ($rows as $row) {
84 | $values = array();
85 |
86 | foreach ($columns as $column) {
87 | if (array_key_exists($column, $row)) {
88 | $values[] = $this->value($row[$column]);
89 | } else {
90 | $values[] = 'DEFAULT';
91 | }
92 | }
93 |
94 | $lists[] = $this->raw("(" . implode(", ", $values) . ")");
95 | }
96 |
97 | return $this('INSERT INTO ::table (::columns) VALUES ::values', array(
98 | 'table' => $this->table($table),
99 | 'columns' => $this->ident($columns),
100 | 'values' => $lists
101 | ));
102 | }
103 |
104 | /**
105 | * Insert multiple rows using a prepared statement (directly executed).
106 | *
107 | * Prepare a statement and execute it once per row using bound params.
108 | * Does not support SQL fragments in row data.
109 | *
110 | * @param string $table
111 | * @param array|\Traversable $rows
112 | * @return Result The prepared result
113 | */
114 | public function insertPrepared($table, $rows)
115 | {
116 | if ($this->empt($rows)) {
117 | return $this(self::EMPTY_STATEMENT)->prepare();
118 | }
119 | $columns = $this->columns($rows);
120 |
121 | $prepared = $this('INSERT INTO ::table (::columns) VALUES ::values', array(
122 | 'table' => $this->table($table),
123 | 'columns' => $this->ident($columns),
124 | 'values' => $this('(?' . str_repeat(', ?', count($columns) - 1) . ')')
125 | ))->prepare();
126 |
127 | foreach ($rows as $row) {
128 | $values = array();
129 |
130 | foreach ($columns as $column) {
131 | $values[] = (string) $this->format(@$row[$column]);
132 | }
133 | $prepared->exec($values);
134 | }
135 | }
136 |
137 | /**
138 | * Build an update statement.
139 | *
140 | * UPDATE ::table SET ::set [WHERE ::where] [::limit]
141 | *
142 | * @param string $table
143 | * @param array|\Traversable $data
144 | * @param array|string $where
145 | * @param array|mixed $params
146 | * @return Fragment
147 | */
148 | public function update($table, $data, $where = array(), $params = array())
149 | {
150 | if ($this->empt($data)) {
151 | return $this(self::EMPTY_STATEMENT);
152 | }
153 |
154 | return $this('UPDATE ::table SET ::set WHERE ::where ::limit', array(
155 | 'table' => $this->table($table),
156 | 'set' => $this->assign($data),
157 | 'where' => $this->where($where, $params),
158 | 'limit' => $this()
159 | ));
160 | }
161 |
162 | /**
163 | * Build a delete statement.
164 | *
165 | * DELETE FROM ::table [WHERE ::where] [::limit]
166 | *
167 | * @param string $table
168 | * @param array|string $where
169 | * @param array|mixed $params
170 | * @return Fragment
171 | */
172 | public function delete($table, $where = array(), $params = array())
173 | {
174 | return $this('DELETE FROM ::table WHERE ::where ::limit', array(
175 | 'table' => $this->table($table),
176 | 'where' => $this->where($where, $params),
177 | 'limit' => $this()
178 | ));
179 | }
180 |
181 | /**
182 | * Build a conditional expression fragment.
183 | *
184 | * @param array|string $condition
185 | * @param array|mixed $params
186 | * @param Fragment|null $before
187 | * @return Fragment
188 | */
189 | public function where($condition = null, $params = array(), $before = null)
190 | {
191 | // empty condition evaluates to true
192 | if (empty($condition)) {
193 | return $before ? $before : $this('1=1');
194 | }
195 |
196 | // conditions in key-value array
197 | if (is_array($condition)) {
198 | $cond = $before;
199 |
200 | foreach ($condition as $k => $v) {
201 | $cond = $this->where($k, $v, $cond);
202 | }
203 |
204 | return $cond;
205 | }
206 |
207 | // shortcut for basic "column is (in) value"
208 | if (preg_match('/^[a-z0-9_.`"]+$/i', $condition)) {
209 | $condition = $this->is($condition, $params);
210 | } else {
211 | $condition = $this($condition, $params);
212 | }
213 |
214 | if ($before && (string) $before !== '1=1') {
215 | return $this('(??) AND (??)', array($before, $condition));
216 | }
217 |
218 | return $condition;
219 | }
220 |
221 | /**
222 | * Build a negated conditional expression fragment.
223 | *
224 | * @param string $key
225 | * @param mixed $value
226 | * @param Fragment|null $before
227 | * @return Fragment
228 | */
229 | public function whereNot($key, $value = array(), $before = null)
230 | {
231 | // key-value array
232 | if (is_array($key)) {
233 | $cond = $before;
234 |
235 | foreach ($key as $k => $v) {
236 | $cond = $this->whereNot($k, $v, $cond);
237 | }
238 |
239 | return $cond;
240 | }
241 |
242 | // "column is not (in) value"
243 | $condition = $this->isNot($key, $value);
244 |
245 | if ($before && (string) $before !== '1=1') {
246 | return $this('(??) AND ??', array($before, $condition));
247 | }
248 |
249 | return $condition;
250 | }
251 |
252 | /**
253 | * Build an ORDER BY fragment.
254 | *
255 | * @param string $column
256 | * @param string $direction Must be ASC or DESC
257 | * @param Fragment|null $before
258 | * @return Fragment
259 | */
260 | public function orderBy($column, $direction = 'ASC', $before = null)
261 | {
262 | if (!preg_match('/^asc|desc$/i', $direction)) {
263 | throw new Exception('Invalid ORDER BY direction: ' . $direction);
264 | }
265 |
266 | return $this->raw(
267 | ($before && (string) $before !== '' ? ($before . ', ') : 'ORDER BY ') .
268 | $this->ident($column) . ' ' . $direction
269 | );
270 | }
271 |
272 | /**
273 | * Build a LIMIT fragment.
274 | *
275 | * @param int $count
276 | * @param int $offset
277 | * @return Fragment
278 | */
279 | public function limit($count = null, $offset = null)
280 | {
281 | if ($count !== null) {
282 | $count = intval($count);
283 | if ($count < 1) {
284 | throw new Exception('Invalid LIMIT count: ' . $count);
285 | }
286 |
287 | if ($offset !== null) {
288 | $offset = intval($offset);
289 | if ($offset < 0) {
290 | throw new Exception('Invalid LIMIT offset: ' . $offset);
291 | }
292 |
293 | return $this->raw('LIMIT ' . $count . ' OFFSET ' . $offset);
294 | }
295 |
296 | return $this->raw('LIMIT ' . $count);
297 | }
298 |
299 | return $this();
300 | }
301 |
302 | /**
303 | * Build an SQL condition expressing that "$column is $value",
304 | * or "$column is in $value" if $value is an array. Handles null
305 | * and fragments like $dop("NOW()") correctly.
306 | *
307 | * @param string $column
308 | * @param mixed|array $value
309 | * @param bool $not
310 | * @return Fragment
311 | */
312 | public function is($column, $value, $not = false)
313 | {
314 | $bang = $not ? '!' : '';
315 | $or = $not ? ' AND ' : ' OR ';
316 | $novalue = $not ? '1=1' : '0=1';
317 | $not = $not ? ' NOT' : '';
318 |
319 | // always treat value as array
320 | if (!is_array($value)) {
321 | $value = array($value);
322 | }
323 |
324 | // always quote column identifier
325 | $column = $this->ident($column);
326 |
327 | if (count($value) === 1) {
328 | // use single column comparison if count is 1
329 | $value = $value[0];
330 |
331 | if ($value === null) {
332 | return $this->raw($column . ' IS' . $not . ' NULL');
333 | } else {
334 | return $this->raw($column . ' ' . $bang . '= ' . $this->value($value));
335 | }
336 | } elseif (count($value) > 1) {
337 | // if we have multiple values, use IN clause
338 |
339 | $values = array();
340 | $null = false;
341 |
342 | foreach ($value as $v) {
343 | if ($v === null) {
344 | $null = true;
345 | } else {
346 | $values[] = $this->value($v);
347 | }
348 | }
349 |
350 | $clauses = array();
351 |
352 | if (!empty($values)) {
353 | $clauses[] = $column . $not . ' IN (' . implode(', ', $values) . ')';
354 | }
355 |
356 | if ($null) {
357 | $clauses[] = $column . ' IS' . $not . ' NULL';
358 | }
359 |
360 | return $this->raw(implode($or, $clauses));
361 | }
362 |
363 | return $this->raw($novalue);
364 | }
365 |
366 | /**
367 | * Build an SQL condition expressing that "$column is not $value"
368 | * or "$column is not in $value" if $value is an array. Handles null
369 | * and fragments like $dop("NOW()") correctly.
370 | *
371 | * @param string $column
372 | * @param mixed|array $value
373 | * @return Fragment
374 | */
375 | public function isNot($column, $value)
376 | {
377 | return $this->is($column, $value, true);
378 | }
379 |
380 | /**
381 | * Build an assignment fragment, e.g. for UPDATE.
382 | *
383 | * @param array|\Traversable $data
384 | * @return Fragment
385 | */
386 | public function assign($data)
387 | {
388 | $assign = array();
389 |
390 | foreach ($data as $column => $value) {
391 | $assign[] = $this->ident($column) . ' = ' . $this->value($value);
392 | }
393 |
394 | return $this->raw(implode(', ', $assign));
395 | }
396 |
397 | /**
398 | * Quote a value for SQL.
399 | *
400 | * @param mixed $value
401 | * @return Fragment
402 | */
403 | public function value($value)
404 | {
405 | if (is_array($value)) {
406 | return $this->raw(implode(', ', array_map(array($this, 'value'), $value)));
407 | }
408 |
409 | if ($value instanceof Fragment) {
410 | return $value;
411 | }
412 |
413 | if ($value === null) {
414 | return $this('NULL');
415 | }
416 |
417 | $value = $this->format($value);
418 |
419 | if (is_float($value)) {
420 | $value = strval($value);
421 | }
422 |
423 | if ($value === false) {
424 | $value = '0';
425 | }
426 |
427 | if ($value === true) {
428 | $value = '1';
429 | }
430 |
431 | return $this->raw($this->pdo()->quote($value));
432 | }
433 |
434 | /**
435 | * Format a value for SQL, e.g. DateTime objects.
436 | *
437 | * @param mixed $value
438 | * @return string
439 | */
440 | public function format($value)
441 | {
442 | if ($value instanceof \DateTime) {
443 | $value = clone $value;
444 | $value->setTimeZone(new \DateTimeZone('UTC'));
445 | return $value->format('Y-m-d H:i:s');
446 | }
447 |
448 | return $value;
449 | }
450 |
451 | /**
452 | * Quote a table name.
453 | *
454 | * Default implementation is just quoting as an identifier.
455 | * Override for table prefixing etc.
456 | *
457 | * @param string $name
458 | * @return Fragment
459 | */
460 | public function table($name)
461 | {
462 | return $this->ident($name);
463 | }
464 |
465 | /**
466 | * Quote identifier(s).
467 | *
468 | * @param mixed $ident Must be 64 or less characters.
469 | * @return Fragment
470 | */
471 | public function ident($ident)
472 | {
473 | if (is_array($ident)) {
474 | return $this->raw(implode(', ', array_map(array($this, 'ident'), $ident)));
475 | }
476 |
477 | if ($ident instanceof Fragment) {
478 | return $ident;
479 | }
480 |
481 | if (strlen($ident) > 64) {
482 | throw new Exception('Identifier is longer than 64 characters');
483 | }
484 |
485 | $d = $this->identDelimiter;
486 |
487 | return $this->raw($d . str_replace($d, $d . $d, $ident) . $d);
488 | }
489 |
490 | /**
491 | * @see Connection::fragment
492 | */
493 | public function __invoke($sql = '', $params = array())
494 | {
495 | return $this->fragment($sql, $params);
496 | }
497 |
498 | /**
499 | * Create an SQL fragment, optionally with bound params.
500 | *
501 | * @param string|Fragment $sql
502 | * @param array $params
503 | * @return Fragment
504 | */
505 | public function fragment($sql = '', $params = array())
506 | {
507 | if ($sql instanceof Fragment) {
508 | return $sql->bind($params);
509 | }
510 | return new Fragment($this, $sql, $params);
511 | }
512 |
513 | /**
514 | * Create a raw SQL fragment, optionally with bound params.
515 | * The fragment will not be resolved, i.e. ?? and :: params ignored.
516 | *
517 | * @param string|Fragment $sql
518 | * @param array $params
519 | * @return Fragment
520 | */
521 | public function raw($sql = '', $params = array())
522 | {
523 | return $this($sql, $params)->raw();
524 | }
525 |
526 | //
527 |
528 | /**
529 | * Query last insert id.
530 | *
531 | * For PostgreSQL, the sequence name is required.
532 | *
533 | * @param string|null $sequence
534 | * @return mixed|null
535 | */
536 | public function lastInsertId($sequence = null)
537 | {
538 | try {
539 | return $this->pdo->lastInsertId($sequence);
540 | } catch (\PDOException $ex) {
541 | $message = $ex->getMessage();
542 |
543 | if (strpos($message, '55000') !== false) {
544 | // we can safely ignore this PostgreSQL error:
545 | // SQLSTATE[55000]: Object not in prerequisite state: 7
546 | // ERROR: lastval is not yet defined in this session
547 | return null;
548 | }
549 |
550 | throw $ex;
551 | }
552 | }
553 |
554 | //
555 |
556 | /**
557 | * Execute a transaction.
558 | *
559 | * Nested transactions are treated as part of the outer transaction.
560 | *
561 | * @param callable $t The transaction body
562 | * @return mixed The return value of calling $t
563 | */
564 | public function transaction($t)
565 | {
566 | if (!is_callable($t)) {
567 | throw new Exception('Transaction must be callable');
568 | }
569 |
570 | $pdo = $this->pdo();
571 |
572 | if ($pdo->inTransaction()) {
573 | return call_user_func($t, $this);
574 | }
575 |
576 | $pdo->beginTransaction();
577 |
578 | try {
579 | $return = call_user_func($t, $this);
580 | $pdo->commit();
581 | return $return;
582 | } catch (\Exception $ex) {
583 | $pdo->rollBack();
584 | throw $ex;
585 | }
586 | }
587 |
588 | //
589 |
590 | /**
591 | * Return whether the given array or Traversable is empty.
592 | *
593 | * @param array|\Traversable
594 | * @return bool
595 | */
596 | public function empt($traversable)
597 | {
598 | foreach ($traversable as $_) {
599 | return false;
600 | }
601 |
602 | return true;
603 | }
604 |
605 | /**
606 | * Get list of all columns used in the given rows.
607 | *
608 | * @param array|\Traversable $rows
609 | * @return array
610 | */
611 | public function columns($rows)
612 | {
613 | if (!$rows) {
614 | return array();
615 | }
616 |
617 | $columns = array();
618 |
619 | foreach ($rows as $row) {
620 | foreach ($row as $column => $value) {
621 | $columns[$column] = true;
622 | }
623 | }
624 |
625 | return array_keys($columns);
626 | }
627 |
628 | /**
629 | * Return rows mapped to a column, multiple columns or using a function.
630 | *
631 | * @param array|Fragment|Result $rows Rows
632 | * @param int|string|array|function $fn Column, columns or function
633 | * @return array
634 | */
635 | public function map($rows, $fn)
636 | {
637 | if (is_callable(array($rows, 'fetchAll'))) {
638 | $rows = $rows->fetchAll();
639 | }
640 |
641 | if (is_array($fn)) {
642 | $columns = $fn;
643 | $fn = function ($row) use ($columns) {
644 | $mapped = array();
645 | foreach ($columns as $column) {
646 | $mapped[$column] = @$row[$column];
647 | }
648 | return $mapped;
649 | };
650 | } elseif (!is_callable($fn)) {
651 | $column = $fn;
652 | $fn = function ($row) use ($column) {
653 | return $row[$column];
654 | };
655 | }
656 |
657 | return array_map($fn, $rows);
658 | }
659 |
660 | /**
661 | * Return rows filtered by column-value equality (non-strict) or function.
662 | *
663 | * @param array|Fragment|Result $rows Rows
664 | * @param int|string|array|function $fn Column, column-value pairs or function
665 | * @param mixed $value
666 | * @return array
667 | */
668 | public function filter($rows, $fn, $value = null)
669 | {
670 | if (is_callable(array($rows, 'fetchAll'))) {
671 | $rows = $rows->fetchAll();
672 | }
673 |
674 | if (is_array($fn)) {
675 | $columns = $fn;
676 | $fn = function ($row) use ($columns) {
677 | foreach ($columns as $column => $value) {
678 | if (@$row[$column] != $value) {
679 | return false;
680 | }
681 | }
682 | return true;
683 | };
684 | } elseif (!is_callable($fn)) {
685 | $column = $fn;
686 | $fn = function ($row) use ($column, $value) {
687 | return @$row[$column] == $value;
688 | };
689 | }
690 |
691 | return array_values(array_filter($rows, $fn));
692 | }
693 |
694 | //
695 |
696 | /**
697 | * Execution callback.
698 | *
699 | * Override to log or measure statement execution.
700 | * Must call $callback.
701 | *
702 | * @param Fragment $statement
703 | * @param function $callback
704 | */
705 | public function execCallback($statement, $callback)
706 | {
707 | $this->beforeExec($statement);
708 | $callback();
709 | }
710 |
711 | /**
712 | * @deprecated Override execCallback instead.
713 | * @param Fragment $statement
714 | */
715 | public function beforeExec($statement)
716 | {
717 | }
718 |
719 | //
720 |
721 | /**
722 | * Get PDO driver name.
723 | *
724 | * @return string
725 | */
726 | public function driver()
727 | {
728 | return $this->pdo()->getAttribute(\PDO::ATTR_DRIVER_NAME);
729 | }
730 |
731 | /**
732 | * Return wrapped PDO.
733 | *
734 | * @return \PDO
735 | */
736 | public function pdo()
737 | {
738 | return $this->pdo;
739 | }
740 |
741 | //
742 |
743 | /** @var \PDO */
744 | protected $pdo;
745 |
746 | /** @var string */
747 | protected $identDelimiter;
748 |
749 | /** @var string */
750 | const EMPTY_STATEMENT = 'SELECT 1 WHERE 0=1';
751 | }
752 |
--------------------------------------------------------------------------------