├── .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 | [![Build Status](https://travis-ci.org/morris/dop.svg?branch=master)](https://travis-ci.org/morris/dop) 4 | [![Test Coverage](https://codeclimate.com/github/morris/dop/badges/coverage.svg)](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 | --------------------------------------------------------------------------------