├── .editorconfig ├── .gitattributes ├── .gitignore ├── .php-cs-fixer.php ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── composer.json └── src ├── BulkInsertResult.php ├── InsertResult.php ├── Options.php ├── PeachySql.php ├── QueryBuilder ├── Delete.php ├── Insert.php ├── Query.php ├── Select.php ├── Selector.php ├── SqlParams.php └── Update.php ├── QueryableSelector.php ├── SqlException.php └── Statement.php /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | eclint_block_comment_start = /* 13 | eclint_block_comment = * 14 | eclint_block_comment_end = */ 15 | 16 | [*.md] 17 | trim_trailing_whitespace = false 18 | 19 | [*.yml] 20 | indent_size = 2 21 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | /.github/ export-ignore 2 | /test/ export-ignore 3 | /phpunit.xml export-ignore 4 | /phpstan.neon export-ignore 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .phpunit.result.cache 3 | .php-cs-fixer.cache 4 | 5 | /vendor/ 6 | /composer.lock 7 | /test/config.php 8 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | path([ 5 | 'src/', 6 | 'test/', 7 | ]) 8 | ->ignoreVCSIgnored(true) 9 | ->append([__FILE__]) 10 | ->in(__DIR__); 11 | 12 | $config = new PhpCsFixer\Config(); 13 | return $config 14 | ->setRules([ 15 | '@PER-CS' => true, 16 | 'align_multiline_comment' => true, 17 | 'array_syntax' => true, 18 | 'binary_operator_spaces' => true, 19 | 'class_attributes_separation' => ['elements' => ['method' => 'one']], 20 | 'class_reference_name_casing' => true, 21 | 'clean_namespace' => true, 22 | 'combine_consecutive_unsets' => true, 23 | 'declare_parentheses' => true, 24 | 'integer_literal_case' => true, 25 | 'lambda_not_used_import' => true, 26 | 'linebreak_after_opening_tag' => true, 27 | 'method_chaining_indentation' => true, 28 | 'multiline_comment_opening_closing' => true, 29 | 'native_function_casing' => true, 30 | 'no_alternative_syntax' => true, 31 | 'no_blank_lines_after_phpdoc' => true, 32 | 'no_empty_comment' => true, 33 | 'no_empty_phpdoc' => true, 34 | 'no_empty_statement' => true, 35 | 'no_extra_blank_lines' => true, 36 | 'no_spaces_around_offset' => true, 37 | 'no_superfluous_phpdoc_tags' => true, 38 | 'no_unneeded_control_parentheses' => true, 39 | 'no_unused_imports' => true, 40 | 'no_useless_return' => true, 41 | 'no_whitespace_before_comma_in_array' => true, 42 | 'object_operator_without_whitespace' => true, 43 | 'ordered_imports' => ['sort_algorithm' => 'alpha'], 44 | 'phpdoc_indent' => true, 45 | 'phpdoc_no_empty_return' => true, 46 | 'phpdoc_single_line_var_spacing' => true, 47 | 'return_assignment' => true, 48 | 'semicolon_after_instruction' => true, 49 | 'single_class_element_per_statement' => true, 50 | 'single_space_around_construct' => true, 51 | 'space_after_semicolon' => true, 52 | 'standardize_not_equals' => true, 53 | 'trim_array_spaces' => true, 54 | 'type_declaration_spaces' => true, 55 | 'types_spaces' => true, 56 | 'whitespace_after_comma_in_array' => ['ensure_single_space' => true], 57 | ]) 58 | ->setFinder($finder); 59 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [7.0.1] - 2025-01-26 4 | ### Changed 5 | - Improved some type annotations and simplified internal test configuration. 6 | - Switched from Psalm to PHPStan for static analysis. 7 | 8 | ### Fixed 9 | - `floatSelectedAsString` option is now `false` for PostgreSQL on PHP 8.4. 10 | 11 | 12 | ## [7.0.0] - 2024-10-29 13 | ### Added 14 | - Official support for PostgreSQL. 15 | - It is now possible to bind and set binary column values in a prepared statement which is executed multiple times. 16 | 17 | ### Changed 18 | - Rewrote library using PDO instead of driver-specific connection objects. 19 | Rather than instantiating a `Mysql` or `SqlServer` subclass, simply construct 20 | `PeachySql` with the PDO object for your connection. 21 | - Moved to `DevTheorem` namespace. 22 | 23 | > [!IMPORTANT] 24 | > If using SQL Server, make sure your PDO connection has the `PDO::SQLSRV_ATTR_FETCHES_NUMERIC_TYPE` 25 | > option set to `true`, so column values are returned with the same native types as before. 26 | 27 | - When using MySQL, the `Statement` object no longer has a `getInsertId()` method. 28 | This does not affect shorthand insert methods, however, which provide the insert IDs just like before. 29 | - The `getAffected()` method on a select query result now returns the number of selected rows instead of `-1` for MySQL. 30 | - PHP 8.1+ is now required. 31 | 32 | ### Removed 33 | - All previously deprecated methods. 34 | - Unnecessary `$length` parameter from `makeBinaryParam()`. 35 | - `SqlException` no longer has properties for the failed query and params. 36 | 37 | 38 | ## [6.3.1] - 2024-10-13 39 | ### Changed 40 | - Improved `makeBinaryParam()` implementation for SQL Server. 41 | 42 | ### Fixed 43 | - Maximum number of MySQL bound parameters. 44 | 45 | ### Deprecated 46 | - Unnecessary `Options` getters and setters. 47 | 48 | 49 | ## [6.3.0] - 2024-10-05 50 | ### Changed 51 | - Use `mysqli_stmt::get_result()` instead of `bind_result()` internally 52 | to slightly improve performance and reduce memory usage. 53 | 54 | ### Deprecated 55 | - Unnecessary getter methods (in `InsertResult`, `BulkInsertResult`, `SqlParams`, and `SqlException`). 56 | 57 | 58 | ## [6.2.0] - 2022-03-14 59 | ### Added 60 | - Support for empty bulk insert queries (previously an error was thrown). 61 | 62 | 63 | ## [6.1.0] - 2021-12-15 64 | ### Added 65 | - Shorthand `select()` method which takes a `SqlParams` object to allow 66 | bound params in the base select query. 67 | - Support for MySQL exception error handling mode (default in PHP 8.1). 68 | 69 | 70 | ## [6.0.3] - 2021-08-06 71 | ### Changed 72 | - Improved type declarations for static analysis. 73 | 74 | 75 | ## [6.0.2] - 2021-02-11 76 | ### Changed 77 | - Specified additional types and enabled Psalm static analysis. 78 | - PHP 7.4+ is now required. 79 | 80 | 81 | ## [6.0.1] - 2019-08-05 82 | ### Changed 83 | - Implemented missing return type declarations. 84 | - Excluded additional test files from production bundle. 85 | 86 | 87 | ## [6.0.0] Deprecation Elimination - 2019-01-16 88 | ### Added 89 | - Scaler and return type declarations. 90 | 91 | ### Removed 92 | - Support for HHVM as well as PHP versions prior to 7.1. 93 | - Unnecessary `$maximum` parameter from `Selector::offset()` method. 94 | - All previously-deprecated methods and options. 95 | 96 | 97 | ## [5.5.1] Differentiated Bit - 2017-11-09 98 | ### Added 99 | - Support for using `makeBinaryParam()` with nullable columns 100 | (issue [#5](https://github.com/devtheorem/peachy-sql/issues/5)). 101 | 102 | 103 | ## [5.5.0] Null Appreciation - 2017-10-19 104 | ### Added 105 | - New `nu` and `nn` shorthand operators to filter where a column is or is not null. 106 | 107 | ### Deprecated 108 | - Ability to use null values with `eq` and `ne` operators. 109 | 110 | 111 | ## [5.4.0] Boolean Affectation - 2017-03-08 112 | ### Added 113 | - `makeBinaryParam()` method. 114 | 115 | ### Fixed 116 | - `Statement::getAffected()` method now consistently returns -1 when no affected count is available. 117 | - "Incorrect integer value" MySQL error when binding a false value. 118 | 119 | 120 | ## [5.3.1] Deprecation Proclamation - 2017-01-31 121 | ### Changed 122 | - Updated readme to document `offset()` method instead of deprecated `paginate()` method. 123 | 124 | ### Deprecated 125 | - Unnecessary option getter/setter methods (`setTable()`, `getTable()`, 126 | `setAutoIncrementValue()`, `getAutoIncrementValue()`, `setIdColumn()`, `getIdColumn()`). 127 | 128 | 129 | ## [5.3.0] Descending Increase - 2016-11-04 130 | ### Added 131 | - `Selector::offset()` method to enable setting an offset that isn't a multiple of the page size. 132 | 133 | ### Deprecated 134 | - `Selector::paginate()` method. 135 | 136 | 137 | ## [5.2.3] Protracted Refinement - 2016-08-28 138 | ### Added 139 | - Support for generating filters with IS NOT NULL and multiple LIKE operators. 140 | 141 | 142 | ## [5.2.2] Chainable Reparation - 2016-08-25 143 | ### Changed 144 | - Updated dependencies and removed unused code. 145 | 146 | ### Fixed 147 | - Return type of `Selector` functions to support subclass autocompletion. 148 | 149 | 150 | ## [5.2.1] Simple Safety - 2016-07-21 151 | ### Changed 152 | - An exception is now thrown when attempting to use pagination without sorting rows. 153 | - Qualified column identifiers are now automatically escaped. As a 154 | consequence, column names containing periods are no longer supported. 155 | 156 | 157 | ## [5.2.0] Intermediate Injection - 2016-07-07 158 | ### Added 159 | - `selectFrom()`, `insertRow()`, `insertRows()`, `updateRows()`, and `deleteFrom()` 160 | methods which can be passed a table name rather than depending on options 161 | passed to the PeachySQL constructor. This simplifies dependency injection 162 | and also removes the need for implementation-specific options. The new 163 | `selectFrom()` method also supports pagination and more complex sorting/filtering. 164 | 165 | ### Deprecated 166 | - Old shorthand methods (`select()`, `insertBulk()`, `insertOne()`, `update()`, and `delete()`) 167 | 168 | 169 | ## [5.1.0] Futuristic Resourcefulness - 2016-04-15 170 | ### Changed 171 | - `InsertResult::getId()` now throws an exception if no ID is available. 172 | - Minor code cleanup. 173 | 174 | ### Fixed 175 | - Compatibility issue with PHP 7 SQL Server driver (see 176 | [Microsoft/msphpsql#84](https://github.com/Microsoft/msphpsql/issues/84)). 177 | 178 | ### Removed 179 | - HHVM generator compatibility hack (no longer necessary as of HHVM v3.11). 180 | 181 | 182 | ## [5.0.0] Escaping Execution - 2015-09-16 183 | ### Added 184 | - `prepare()` method which binds parameters and returns a `Statement` 185 | object. This object has an `execute()` method which makes it possible 186 | to run the prepared query multiple times with different values. 187 | 188 | ### Changed 189 | - Column names are now automatically escaped, so shorthand methods can 190 | be used without having to specify a list of valid columns. 191 | - Rather than passing options to the PeachySQL constructor as an associative array, 192 | an `Options` subclass should be passed instead. This object has setters and getters 193 | for each setting, which improves discoverability and refactoring. 194 | 195 | ### Removed 196 | - `setConnection()` and `setOptions()` methods. Options can still be 197 | dynamically changed by calling `getOptions()->setterMethod()`. 198 | 199 | ### Fixed 200 | - Bug where MySQL insert IDs weren't calculated correctly if the 201 | increment value was altered. 202 | 203 | 204 | ## [4.0.2] Preparatory Fixture - 2015-05-11 205 | ### Fixed 206 | - Missing error info for MySQL prepared statement failures 207 | (issue [#4](https://github.com/devtheorem/peachy-sql/issues/4)). 208 | 209 | ### Removed 210 | - Unnecessary `SqlResult::getQuery()` method. 211 | 212 | 213 | ## [4.0.1] Stalwart Sparkle - 2015-02-08 214 | ### Changed 215 | - Unused code cleanup 216 | - Documentation improvements 217 | 218 | 219 | ## [4.0.0] Economical Alternator - 2015-02-06 220 | ### Added 221 | - `SqlResult::getIterator()` method which returns a `Generator`, making it possible to iterate over 222 | very large result sets without running into memory limitations. 223 | - Optional third parameter on `select()` method which accepts an array of 224 | column names to sort by in ascending order. 225 | - `SqlException::getSqlState()` method which returns the standard SQLSTATE code for the failure. 226 | 227 | ### Changed 228 | - `SqlException::getMessage()` now includes the SQL error message for the failed query. 229 | - `SqlException::getCode()` now returns the MySQL or SQL Server error code. 230 | 231 | ### Removed 232 | - PHP 5.4 support (5.5+ is now required - recent versions of HHVM should also work if using MySQL). 233 | - Deprecated `insert()` and `insertAssoc()` methods. 234 | - Deprecated `TSQL` class. 235 | - Ability to call `SqlResult::getFirst()` and `SqlResult::getAll()` multiple 236 | times for a given result (since rows are no longer cached in the object). 237 | 238 | 239 | ## [3.0.1] Uniform Optimization - 2014-12-06 240 | ### Changed 241 | - Improved documentation consistency. 242 | - Minor code cleanup and performance tweaks. 243 | 244 | 245 | ## [3.0.0] Hyperactive Lightyear - 2014-12-02 246 | ### Added 247 | - `insertOne()` and `insertBulk()` methods, which accept an associative array of 248 | columns/values and return `InsertResult` and `BulkInsertResult` objects, respectively. 249 | - It is now possible to bulk-insert an arbitrarily large set of rows. 250 | PeachySQL will automatically batch large inserts to remove limitations 251 | on the maximum number of bound parameters and rows per query. 252 | 253 | ### Changed 254 | 255 | The following classes have been renamed to improve API consistency: 256 | - `PeachySQL` is now `PeachySql` 257 | - `MySQL` is now `Mysql` 258 | - `SQLException` is now `SqlException` 259 | - `SQLResult` is now `SqlResult` 260 | - `MySQLResult` is now `MysqlResult` 261 | 262 | Since class and function names in PHP are case-insensitive (as are file names on some platforms), 263 | these renames do not necessarily break backwards compatibility. However, existing references should 264 | still be updated to avoid confusion. 265 | 266 | ### Deprecated 267 | - `insertAssoc()` method - use `insertOne()` instead. 268 | - `insert()` method - use `insertBulk()` instead. 269 | - `TSQL` class - use `SqlServer` instead. 270 | 271 | ### Removed 272 | - Previously deprecated `SqlResult::getRows()` method. 273 | - Optional callback parameters from `query()` and shorthand methods. 274 | 275 | 276 | ## [2.1.0] Progressive Substitution - 2014-08-03 277 | ### Added 278 | - `SQLResult::getFirst()` method for retrieving the first selected row. 279 | - `SQLResult::getAll()` method for retrieving all selected rows. 280 | 281 | ### Deprecated 282 | - `SQLResult::getRows()` is now a deprecated alias of `SQLResult::getAll()`. 283 | 284 | 285 | ## [2.0.0] Peachy Sequel - 2014-07-29 286 | ### Added 287 | - `begin()`, `commit()`, and `rollback()` methods to support transactions. 288 | - `insertAssoc()` method to easily insert a single row from an associative array. 289 | - `setConnection()` method to change the database connection after instantiation. 290 | - Option to override the default auto increment value for MySQL. 291 | - Custom `PeachySQL\SQLException` thrown for query errors, with methods 292 | to retrieve the error array, SQL query string, and bound parameters. 293 | - Contributing instructions. 294 | 295 | ### Changed 296 | - The library is now namespaced under `PeachySQL`. 297 | - Callbacks for shorthand methods are now optional. If no callback is specified, the methods will 298 | return sensible defaults (e.g. `select()` returns selected rows, `insert()` returns insert IDs, 299 | and `update()` and `delete()` return the number of affected rows). 300 | - A list of valid columns must now be passed to the options array to 301 | generate queries which reference a column. This allows queries to be 302 | generated from user data without the potential for SQL injection attacks. 303 | - Callbacks are now passed a `SQLResult` object, rather than separate 304 | arguments for selected and affected rows. 305 | - Table name and identity column options are now passed to the constructor as an associative array. 306 | - If a flat array of values is passed to `insert()`, the insert ID will 307 | now be returned as an integer instead of an array. 308 | - Updated code to follow the [PSR-2 coding style guide](http://www.php-fig.org/psr/psr-2/). 309 | 310 | ### Removed 311 | - `$dbType` argument from constructor (use `new PeachySQL\MySQL($conn)` 312 | or `new PeachySQL\TSQL($conn)` instead). 313 | - `getTableName()` and `setTableName()` methods (replaced with `getOptions()` and `setOptions()`). 314 | - `$idCol` parameter from `insert()` method (specify via options array instead if using SQL Server). 315 | - `splitRows()` method (not core to PeachySQL's goal). The same functionality is 316 | available in the [ArrayUtils library](https://github.com/theodorejb/array-utils). 317 | 318 | ## Fixed 319 | - Potential error when inserting into a MySQL table without an auto-incremented column. 320 | - Errors are now thrown if required table/column names aren't specified. 321 | 322 | 323 | ## [1.1.1] - 2014-04-20 324 | ### Fixed 325 | - Bug where additional (non-existent) insert IDs were returned when 326 | inserting a single row into a MySQL table with a flat array. 327 | 328 | ### Removed 329 | - Unnecessary usage of variable variables. 330 | 331 | 332 | ## [1.1.0] - 2014-04-11 333 | ### Changed 334 | - The `query()`, `select()`, `insert()`, `update()`, and `delete()` methods now 335 | return the value of their callback function, making it easier to use data outside the callback. 336 | - A flat array of values can now be passed to the `insert()` method to insert a single row. 337 | 338 | 339 | ## [1.0.1] - 2014-03-28 340 | ### Changed 341 | - Simplified `splitRows()` example in readme. 342 | - Short array syntax is now used consistently. 343 | - Minor unit test improvements. 344 | 345 | 346 | ## [1.0.0] - 2014-02-20 347 | - Initial release 348 | 349 | 350 | [7.0.1]: https://github.com/devtheorem/peachy-sql/compare/v7.0.0...v7.0.1 351 | [7.0.0]: https://github.com/devtheorem/peachy-sql/compare/v6.3.1...v7.0.0 352 | [6.3.1]: https://github.com/devtheorem/peachy-sql/compare/v6.3.0...v6.3.1 353 | [6.3.0]: https://github.com/devtheorem/peachy-sql/compare/v6.2.0...v6.3.0 354 | [6.2.0]: https://github.com/devtheorem/peachy-sql/compare/v6.1.0...v6.2.0 355 | [6.1.0]: https://github.com/devtheorem/peachy-sql/compare/v6.0.3...v6.1.0 356 | [6.0.3]: https://github.com/devtheorem/peachy-sql/compare/v6.0.2...v6.0.3 357 | [6.0.2]: https://github.com/devtheorem/peachy-sql/compare/v6.0.1...v6.0.2 358 | [6.0.1]: https://github.com/devtheorem/peachy-sql/compare/v6.0.0...v6.0.1 359 | [6.0.0]: https://github.com/devtheorem/peachy-sql/compare/v5.5.1...v6.0.0 360 | [5.5.1]: https://github.com/devtheorem/peachy-sql/compare/v5.5.0...v5.5.1 361 | [5.5.0]: https://github.com/devtheorem/peachy-sql/compare/v5.4.0...v5.5.0 362 | [5.4.0]: https://github.com/devtheorem/peachy-sql/compare/v5.3.1...v5.4.0 363 | [5.3.1]: https://github.com/devtheorem/peachy-sql/compare/v5.3.0...v5.3.1 364 | [5.3.0]: https://github.com/devtheorem/peachy-sql/compare/v5.2.3...v5.3.0 365 | [5.2.3]: https://github.com/devtheorem/peachy-sql/compare/v5.2.2...v5.2.3 366 | [5.2.2]: https://github.com/devtheorem/peachy-sql/compare/v5.2.1...v5.2.2 367 | [5.2.1]: https://github.com/devtheorem/peachy-sql/compare/v5.2.0...v5.2.1 368 | [5.2.0]: https://github.com/devtheorem/peachy-sql/compare/v5.1.0...v5.2.0 369 | [5.1.0]: https://github.com/devtheorem/peachy-sql/compare/v5.0.0...v5.1.0 370 | [5.0.0]: https://github.com/devtheorem/peachy-sql/compare/v4.0.2...v5.0.0 371 | [4.0.2]: https://github.com/devtheorem/peachy-sql/compare/v4.0.1...v4.0.2 372 | [4.0.1]: https://github.com/devtheorem/peachy-sql/compare/v4.0.0...v4.0.1 373 | [4.0.0]: https://github.com/devtheorem/peachy-sql/compare/v3.0.1...v4.0.0 374 | [3.0.1]: https://github.com/devtheorem/peachy-sql/compare/v3.0.0...v3.0.1 375 | [3.0.0]: https://github.com/devtheorem/peachy-sql/compare/v2.1.0...v3.0.0 376 | [2.1.0]: https://github.com/devtheorem/peachy-sql/compare/v2.0.0...v2.1.0 377 | [2.0.0]: https://github.com/devtheorem/peachy-sql/compare/v1.1.1...v2.0.0 378 | [1.1.1]: https://github.com/devtheorem/peachy-sql/compare/v1.1.0...v1.1.1 379 | [1.1.0]: https://github.com/devtheorem/peachy-sql/compare/v1.0.1...v1.1.0 380 | [1.0.1]: https://github.com/devtheorem/peachy-sql/compare/v1.0.0...v1.0.1 381 | [1.0.0]: https://github.com/devtheorem/peachy-sql/tree/v1.0.0 382 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Dev environment setup 4 | 5 | 1. Install and enable PDO driver for one or more of MySQL, PostgreSQL, or SQL Server. 6 | 2. Install dependencies: `composer install` 7 | 8 | ## Tests 9 | 10 | From a console in the working directory, execute `composer test` to run all unit tests. 11 | 12 | > [!NOTE] 13 | > By default, database tests will attempt to run on a database named `PeachySQL`. 14 | > To override connection settings, create a `test/config.php` file which returns 15 | > an instance of `Config` with the desired property values. 16 | 17 | ## Formatting and static analysis 18 | 19 | * Run `composer cs-fix` to format code following PER Coding Style. 20 | * Run `composer analyze` to detect type-related errors before runtime. 21 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2015 Theodore Brown 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PeachySQL 2 | 3 | PeachySQL is a high-performance query builder and runner which streamlines prepared statements 4 | and working with large datasets. It is officially tested with MySQL, PostgreSQL, and SQL Server, 5 | but it should also work with any standards-compliant database which has a driver for PDO. 6 | 7 | ## Install via Composer 8 | 9 | `composer require devtheorem/peachy-sql` 10 | 11 | ## Usage 12 | 13 | Start by instantiating the `PeachySql` class with a database connection, 14 | which should be an existing [PDO object](https://www.php.net/manual/en/class.pdo.php): 15 | 16 | ```php 17 | use DevTheorem\PeachySQL\PeachySql; 18 | 19 | $server = '(local)\SQLEXPRESS'; 20 | $connection = new PDO("sqlsrv:Server={$server};Database=someDbName", $username, $password, [ 21 | PDO::ATTR_EMULATE_PREPARES => false, 22 | PDO::SQLSRV_ATTR_FETCHES_NUMERIC_TYPE => true, 23 | ]); 24 | 25 | $db = new PeachySql($connection); 26 | ``` 27 | 28 | After instantiation, arbitrary statements can be prepared by passing a 29 | SQL string and array of bound parameters to the `prepare()` method: 30 | 31 | ```php 32 | $sql = "UPDATE Users SET fname = ? WHERE user_id = ?"; 33 | $stmt = $db->prepare($sql, [&$fname, &$id]); 34 | 35 | $nameUpdates = [ 36 | 3 => 'Theodore', 37 | 7 => 'Luke', 38 | ]; 39 | 40 | foreach ($nameUpdates as $id => $fname) { 41 | $stmt->execute(); 42 | } 43 | 44 | $stmt->close(); 45 | ``` 46 | 47 | Most of the time prepared statements only need to be executed a single time. 48 | To make this easier, PeachySQL provides a `query()` method which automatically 49 | prepares, executes, and closes a statement after results are retrieved: 50 | 51 | ```php 52 | $sql = 'SELECT * FROM Users WHERE fname LIKE ? AND lname LIKE ?'; 53 | $result = $db->query($sql, ['theo%', 'b%']); 54 | echo json_encode($result->getAll()); 55 | ``` 56 | 57 | Both `prepare()` and `query()` return a `Statement` object with the following methods: 58 | 59 | | Method | Behavior | 60 | |-----------------|------------------------------------------------------------------------------------------------------------------| 61 | | `execute()` | Executes the prepared statement (automatically called when using `query()`). | 62 | | `getIterator()` | Returns a `Generator` object which can be used to iterate over large result sets without caching them in memory. | 63 | | `getAll()` | Returns all selected rows as an array of associative arrays. | 64 | | `getFirst()` | Returns the first selected row as an associative array (or `null` if no rows were selected). | 65 | | `getAffected()` | Returns the number of rows affected by the query. | 66 | | `close()` | Closes the prepared statement and frees its resources (automatically called when using `query()`). | 67 | 68 | Internally, `getAll()` and `getFirst()` are implemented using `getIterator()`. 69 | As such they can only be called once for a given statement. 70 | 71 | ### Shorthand methods 72 | 73 | PeachySQL comes with five shorthand methods for selecting, inserting, updating, 74 | and deleting records. 75 | 76 | > [!NOTE] 77 | > To prevent SQL injection, the queries PeachySQL generates for these methods 78 | > always use bound parameters for values, and column names are automatically escaped. 79 | 80 | #### select / selectFrom 81 | 82 | The `selectFrom()` method takes a single string argument containing a SQL SELECT query. 83 | It returns an object with three chainable methods: 84 | 85 | 1. `where()` 86 | 2. `orderBy()` 87 | 3. `offset()` 88 | 89 | Additionally, the object has a `getSqlParams()` method which builds the select query, 90 | and a `query()` method which executes the query and returns a `Statement` object. 91 | 92 | ```php 93 | // select all columns and rows in a table, ordered by last name and then first name 94 | $rows = $db->selectFrom("SELECT * FROM Users") 95 | ->orderBy(['lname', 'fname']) 96 | ->query()->getAll(); 97 | 98 | // select from multiple tables with conditions and pagination 99 | $rows = $db->selectFrom("SELECT * FROM Users u INNER JOIN Customers c ON c.CustomerID = u.CustomerID") 100 | ->where(['c.CustomerName' => 'Amazing Customer']) 101 | ->orderBy(['u.fname' => 'desc', 'u.lname' => 'asc']) 102 | ->offset(0, 50) // page 1 with 50 rows per page 103 | ->query()->getIterator(); 104 | ``` 105 | 106 | The `select()` method works the same as `selectFrom()`, but takes a `SqlParams` 107 | object rather than a string and supports bound params in the select query: 108 | 109 | ```php 110 | use DevTheorem\PeachySQL\QueryBuilder\SqlParams; 111 | 112 | $sql = " 113 | WITH UserVisits AS ( 114 | SELECT user_id, COUNT(*) AS recent_visits 115 | FROM UserHistory 116 | WHERE date > ? 117 | GROUP BY user_id 118 | ) 119 | SELECT u.fname, u.lname, uv.recent_visits 120 | FROM Users u 121 | INNER JOIN UserVisits uv ON uv.user_id = u.user_id"; 122 | 123 | $date = (new DateTime('2 months ago'))->format('Y-m-d'); 124 | 125 | $rows = $db->select(new SqlParams($sql, [$date])) 126 | ->where(['u.status' => 'verified']) 127 | ->query()->getIterator(); 128 | ``` 129 | 130 | ##### Where clause generation 131 | 132 | In addition to passing basic column => value arrays to the `where()` method, you can 133 | specify more complex conditions by using arrays as values. For example, passing 134 | `['col' => ['lt' => 15, 'gt' => 5]]` would generate the condition `WHERE col < 15 AND col > 5`. 135 | 136 | Full list of recognized operators: 137 | 138 | | Operator | SQL condition | 139 | |----------|---------------| 140 | | eq | = | 141 | | ne | <> | 142 | | lt | < | 143 | | le | <= | 144 | | gt | > | 145 | | ge | >= | 146 | | lk | LIKE | 147 | | nl | NOT LIKE | 148 | | nu | IS NULL | 149 | | nn | IS NOT NULL | 150 | 151 | If a list of values is passed with the `eq` or `ne` operator, it will generate an 152 | IN(...) or NOT IN(...) condition, respectively. Passing a list with the `lk`, `nl`, 153 | `nu`, or `nn` operator will generate an AND condition for each value. The `lt`, `le`, 154 | `gt`, and `ge` operators cannot be used with a list of values. 155 | 156 | #### insertRow 157 | 158 | The `insertRow()` method allows a single row to be inserted from an associative array. 159 | It returns an `InsertResult` object with readonly `id` and `affected` properties. 160 | 161 | ```php 162 | $userData = [ 163 | 'fname' => 'Donald', 164 | 'lname' => 'Chamberlin' 165 | ]; 166 | 167 | $id = $db->insertRow('Users', $userData)->id; 168 | ``` 169 | 170 | #### insertRows 171 | 172 | The `insertRows()` method makes it possible to bulk-insert multiple rows from an array. 173 | It returns a `BulkInsertResult` object with readonly `ids`, `affected`, and `queryCount` properties. 174 | 175 | ```php 176 | $userData = [ 177 | [ 178 | 'fname' => 'Grace', 179 | 'lname' => 'Hopper' 180 | ], 181 | [ 182 | 'fname' => 'Douglas', 183 | 'lname' => 'Engelbart' 184 | ], 185 | [ 186 | 'fname' => 'Margaret', 187 | 'lname' => 'Hamilton' 188 | ] 189 | ]; 190 | 191 | $result = $db->insertRows('Users', $userData); 192 | $ids = $result->ids; // e.g. [64, 65, 66] 193 | $affected = $result->affected; // 3 194 | $queries = $result->queryCount; // 1 195 | ``` 196 | 197 | An optional third parameter can be passed to `insertRows()` to override the default 198 | identity increment value: 199 | 200 | ```php 201 | $result = $db->insertRows('Users', $userData, 2); 202 | $ids = $result->ids; // e.g. [64, 66, 68] 203 | ``` 204 | 205 | > [!NOTE] 206 | > SQL Server allows a maximum of 1,000 rows to be inserted at a time, and limits individual queries 207 | > to 2,099 or fewer bound parameters. MySQL and PostgreSQL support a maximum of 65,535 bound 208 | > parameters per query. These limits can be easily reached when attempting to bulk-insert hundreds 209 | > or thousands of rows at a time. To avoid these limits, the `insertRows()` method automatically 210 | > splits row sets that exceed the limits into chunks to efficiently insert any number of rows 211 | > (`queryCount` contains the number of required queries). 212 | 213 | #### updateRows and deleteFrom 214 | 215 | The `updateRows()` method takes three arguments: a table name, an associative array of 216 | columns/values to update, and a WHERE array to filter which rows are updated. 217 | 218 | The `deleteFrom()` method takes a table name and a WHERE array to filter the rows to delete. 219 | 220 | Both methods return the number of affected rows. 221 | 222 | ```php 223 | // update the user with user_id 4 224 | $newData = ['fname' => 'Raymond', 'lname' => 'Boyce']; 225 | $db->updateRows('Users', $newData, ['user_id' => 4]); 226 | 227 | // delete users with IDs 1, 2, and 3 228 | $userTable->deleteFrom('Users', ['user_id' => [1, 2, 3]]); 229 | ``` 230 | 231 | ### Transactions 232 | 233 | Call the `begin()` method to start a transaction. `prepare()`, `execute()`, `query()` 234 | and any of the shorthand methods can then be called as needed, before committing 235 | or rolling back the transaction with `commit()` or `rollback()`. 236 | 237 | ### Binary columns 238 | 239 | In order to insert/update raw binary data (e.g. to a binary, blob, or bytea column), 240 | the bound parameter must have its encoding type set to binary. PeachySQL provides a 241 | `makeBinaryParam()` method to simplify this: 242 | 243 | ```php 244 | $db->insertRow('Users', [ 245 | 'fname' => 'Tony', 246 | 'lname' => 'Hoare', 247 | 'uuid' => $db->makeBinaryParam(Uuid::uuid4()->getBytes()), 248 | ]); 249 | ``` 250 | 251 | ## Author 252 | 253 | Theodore Brown 254 | 255 | 256 | ## License 257 | 258 | MIT 259 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "devtheorem/peachy-sql", 3 | "description": "A high-performance query builder and runner for PHP", 4 | "license": "MIT", 5 | "keywords": [ 6 | "database", 7 | "MySQL", 8 | "PostgreSQL", 9 | "SQL Server", 10 | "sqlsrv" 11 | ], 12 | "authors": [ 13 | { 14 | "name": "Theodore Brown", 15 | "email": "theodorejb@outlook.com" 16 | } 17 | ], 18 | "require": { 19 | "php": ">=8.1", 20 | "ext-pdo": "*" 21 | }, 22 | "require-dev": { 23 | "friendsofphp/php-cs-fixer": "^3.75", 24 | "phpstan/phpstan": "^2.1.14", 25 | "phpunit/phpunit": "^10.5", 26 | "ramsey/uuid": "^4.2.3" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "DevTheorem\\PeachySQL\\": "src/" 31 | } 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { 35 | "DevTheorem\\PeachySQL\\Test\\": "test/" 36 | } 37 | }, 38 | "config": { 39 | "sort-packages": true 40 | }, 41 | "scripts": { 42 | "analyze": "phpstan analyze", 43 | "cs-fix": "php-cs-fixer fix -v", 44 | "test": "phpunit", 45 | "test-mssql": "phpunit --exclude-group mysql,pgsql", 46 | "test-mysql": "phpunit --exclude-group mssql,pgsql", 47 | "test-pgsql": "phpunit --exclude-group mssql,mysql", 48 | "test-without-mssql": "phpunit --exclude-group mssql" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/BulkInsertResult.php: -------------------------------------------------------------------------------- 1 | $ids The IDs of the inserted rows 12 | * @param int $affected The number of affected rows 13 | * @param int $queryCount The number of individual queries used to perform the bulk insert 14 | */ 15 | public function __construct( 16 | public readonly array $ids, 17 | public readonly int $affected, 18 | public readonly int $queryCount = 1, 19 | ) {} 20 | } 21 | -------------------------------------------------------------------------------- /src/InsertResult.php: -------------------------------------------------------------------------------- 1 | driver === 'sqlsrv') { 40 | // https://learn.microsoft.com/en-us/sql/sql-server/maximum-capacity-specifications-for-sql-server 41 | $this->maxBoundParams = 2100 - 1; 42 | $this->maxInsertRows = 1000; 43 | $this->affectedIsRowCount = false; 44 | $this->fetchNextSyntax = true; 45 | $this->sqlsrvBinaryEncoding = true; 46 | $this->multiRowset = true; 47 | } elseif ($this->driver === 'mysql') { 48 | $this->lastIdIsFirstOfBatch = true; 49 | $this->identifierQuote = '`'; // needed since not everyone uses ANSI mode 50 | } elseif ($this->driver === 'pgsql') { 51 | $this->binarySelectedAsStream = true; 52 | $this->nativeBoolColumns = true; 53 | 54 | if (PHP_VERSION_ID < 80_400) { 55 | $this->floatSelectedAsString = true; 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/PeachySql.php: -------------------------------------------------------------------------------- 1 | conn = $connection; 22 | 23 | if ($options === null) { 24 | /** @var string $driver */ 25 | $driver = $connection->getAttribute(PDO::ATTR_DRIVER_NAME); 26 | $options = new Options($driver); 27 | } 28 | 29 | $this->options = $options; 30 | } 31 | 32 | /** 33 | * Begins a transaction 34 | * @throws SqlException if an error occurs 35 | */ 36 | public function begin(): void 37 | { 38 | if (!$this->conn->beginTransaction()) { 39 | /** @phpstan-ignore argument.type */ 40 | throw $this->getError('Failed to begin transaction', $this->conn->errorInfo()); 41 | } 42 | } 43 | 44 | /** 45 | * Commits a transaction begun with begin() 46 | * @throws SqlException if an error occurs 47 | */ 48 | public function commit(): void 49 | { 50 | if (!$this->conn->commit()) { 51 | /** @phpstan-ignore argument.type */ 52 | throw $this->getError('Failed to commit transaction', $this->conn->errorInfo()); 53 | } 54 | } 55 | 56 | /** 57 | * Rolls back a transaction begun with begin() 58 | * @throws SqlException if an error occurs 59 | */ 60 | public function rollback(): void 61 | { 62 | if (!$this->conn->rollback()) { 63 | /** @phpstan-ignore argument.type */ 64 | throw $this->getError('Failed to roll back transaction', $this->conn->errorInfo()); 65 | } 66 | } 67 | 68 | /** 69 | * Takes a binary string and returns a value that can be bound to an insert/update statement 70 | * @return array{0: string|null, 1: int, 2: int, 3: mixed} 71 | */ 72 | final public function makeBinaryParam(?string $binaryStr): array 73 | { 74 | $driverOptions = $this->options->sqlsrvBinaryEncoding ? PDO::SQLSRV_ENCODING_BINARY : null; 75 | return [$binaryStr, PDO::PARAM_LOB, 0, $driverOptions]; 76 | } 77 | 78 | /** 79 | * @param array{0: string, 1: int|null, 2: string|null} $error 80 | * @internal 81 | */ 82 | public static function getError(string $message, array $error): SqlException 83 | { 84 | $code = $error[1] ?? 0; 85 | $details = $error[2] ?? ''; 86 | $sqlState = $error[0]; 87 | 88 | return new SqlException($message, $code, $details, $sqlState); 89 | } 90 | 91 | /** 92 | * Returns a prepared statement which can be executed multiple times. 93 | * @param list $params 94 | * @throws SqlException if an error occurs 95 | */ 96 | public function prepare(string $sql, array $params = []): Statement 97 | { 98 | try { 99 | if (!$stmt = $this->conn->prepare($sql)) { 100 | /** @phpstan-ignore argument.type */ 101 | throw $this->getError('Failed to prepare statement', $this->conn->errorInfo()); 102 | } 103 | 104 | $i = 0; 105 | foreach ($params as &$param) { 106 | $i++; 107 | 108 | if (is_bool($param)) { 109 | $stmt->bindParam($i, $param, PDO::PARAM_BOOL); 110 | } elseif (is_int($param)) { 111 | $stmt->bindParam($i, $param, PDO::PARAM_INT); 112 | } elseif (is_array($param)) { 113 | /** @var array{0: mixed, 1: int, 2?: int, 3?: mixed} $param */ 114 | $stmt->bindParam($i, $param[0], $param[1], $param[2] ?? 0, $param[3] ?? null); 115 | } else { 116 | $stmt->bindParam($i, $param, PDO::PARAM_STR); 117 | } 118 | } 119 | } catch (\PDOException $e) { 120 | /** @phpstan-ignore argument.type */ 121 | throw $this->getError('Failed to prepare statement', $this->conn->errorInfo()); 122 | } 123 | 124 | return new Statement($stmt, $this->usedPrepare, $this->options); 125 | } 126 | 127 | /** 128 | * Prepares and executes a single query with bound parameters. 129 | * @param list $params 130 | */ 131 | public function query(string $sql, array $params = []): Statement 132 | { 133 | $this->usedPrepare = false; 134 | $stmt = $this->prepare($sql, $params); 135 | $this->usedPrepare = true; 136 | $stmt->execute(); 137 | return $stmt; 138 | } 139 | 140 | /** 141 | * @param list $colVals 142 | */ 143 | private function insertBatch(string $table, array $colVals, int $identityIncrement = 1): BulkInsertResult 144 | { 145 | $sqlParams = (new Insert($this->options))->buildQuery($table, $colVals); 146 | $result = $this->query($sqlParams->sql, $sqlParams->params); 147 | 148 | try { 149 | $lastId = (int) $this->conn->lastInsertId(); 150 | } catch (\PDOException $e) { 151 | $lastId = 0; 152 | } 153 | 154 | if ($lastId) { 155 | if ($this->options->lastIdIsFirstOfBatch) { 156 | $firstId = $lastId; 157 | $lastId = $firstId + $identityIncrement * (count($colVals) - 1); 158 | } else { 159 | $firstId = $lastId - $identityIncrement * (count($colVals) - 1); 160 | } 161 | 162 | $ids = range($firstId, $lastId, $identityIncrement); 163 | } else { 164 | $ids = []; 165 | } 166 | 167 | return new BulkInsertResult($ids, $result->getAffected()); 168 | } 169 | 170 | public function selectFrom(string $query): QueryableSelector 171 | { 172 | return new QueryableSelector(new SqlParams($query, []), $this); 173 | } 174 | 175 | public function select(SqlParams $query): QueryableSelector 176 | { 177 | return new QueryableSelector($query, $this); 178 | } 179 | 180 | /** 181 | * Inserts one row 182 | * @param ColValues $colVals 183 | */ 184 | public function insertRow(string $table, array $colVals): InsertResult 185 | { 186 | $result = $this->insertBatch($table, [$colVals]); 187 | $ids = $result->ids; 188 | return new InsertResult($ids ? $ids[0] : 0, $result->affected); 189 | } 190 | 191 | /** 192 | * Insert multiple rows 193 | * @param list $colVals 194 | */ 195 | public function insertRows(string $table, array $colVals, int $identityIncrement = 1): BulkInsertResult 196 | { 197 | // check whether the query needs to be split into multiple batches 198 | $batches = Insert::batchRows($colVals, $this->options->maxBoundParams, $this->options->maxInsertRows); 199 | $ids = []; 200 | $affected = 0; 201 | 202 | foreach ($batches as $batch) { 203 | $result = $this->insertBatch($table, $batch, $identityIncrement); 204 | $ids = array_merge($ids, $result->ids); 205 | $affected += $result->affected; 206 | } 207 | 208 | return new BulkInsertResult($ids, $affected, count($batches)); 209 | } 210 | 211 | /** 212 | * Updates the specified columns and values in rows matching the where clause 213 | * Returns the number of affected rows 214 | * @param ColValues $set 215 | * @param WhereClause $where 216 | */ 217 | public function updateRows(string $table, array $set, array $where): int 218 | { 219 | $update = new Update($this->options); 220 | $sqlParams = $update->buildQuery($table, $set, $where); 221 | return $this->query($sqlParams->sql, $sqlParams->params)->getAffected(); 222 | } 223 | 224 | /** 225 | * Deletes rows from the table matching the where clause 226 | * Returns the number of affected rows 227 | * @param WhereClause $where 228 | */ 229 | public function deleteFrom(string $table, array $where): int 230 | { 231 | $delete = new Delete($this->options); 232 | $sqlParams = $delete->buildQuery($table, $where); 233 | return $this->query($sqlParams->sql, $sqlParams->params)->getAffected(); 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/QueryBuilder/Delete.php: -------------------------------------------------------------------------------- 1 | buildWhereClause($where); 18 | $sql = "DELETE FROM {$table}" . $whereClause->sql; 19 | return new SqlParams($sql, $whereClause->params); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/QueryBuilder/Insert.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | class Insert extends Query 10 | { 11 | /** 12 | * Returns the array of columns/values, split into groups containing the largest number of rows possible. 13 | * @param list $colVals 14 | * @return list> 15 | */ 16 | public static function batchRows(array $colVals, int $maxBoundParams, int $maxRows): array 17 | { 18 | $maxRowsPerQuery = count($colVals); 19 | 20 | if ($maxRowsPerQuery === 0) { 21 | return []; 22 | } 23 | 24 | if ($maxBoundParams > 0) { 25 | $maxRowsPerQuery = (int) floor($maxBoundParams / count($colVals[0])); // max bound params divided by params per row 26 | } 27 | 28 | if ($maxRows > 0 && $maxRowsPerQuery > $maxRows) { 29 | $maxRowsPerQuery = $maxRows; 30 | } 31 | 32 | /** @phpstan-ignore argument.type */ 33 | return array_chunk($colVals, $maxRowsPerQuery); 34 | } 35 | 36 | /** 37 | * Generates an INSERT query with placeholders for values 38 | * @param list $colVals 39 | */ 40 | public function buildQuery(string $table, array $colVals): SqlParams 41 | { 42 | if (!$colVals || empty($colVals[0])) { 43 | throw new \Exception('A valid array of columns/values to insert must be specified'); 44 | } 45 | 46 | $columns = $this->escapeColumns(array_keys($colVals[0])); 47 | $insert = "INSERT INTO {$table} (" . implode(', ', $columns) . ')'; 48 | 49 | $valSetStr = ' (' . str_repeat('?,', count($columns) - 1) . '?),'; 50 | $valStr = ' VALUES' . substr_replace(str_repeat($valSetStr, count($colVals)), '', -1); // remove trailing comma 51 | $params = array_merge(...array_map(array_values(...), $colVals)); 52 | 53 | return new SqlParams($insert . $valStr, $params); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/QueryBuilder/Query.php: -------------------------------------------------------------------------------- 1 | 11 | * @psalm-type WhereClause = array> 12 | */ 13 | class Query 14 | { 15 | protected Options $options; 16 | 17 | private const OPERATOR_MAP = [ 18 | 'eq' => '=', 19 | 'ne' => '<>', 20 | 'lt' => '<', 21 | 'le' => '<=', 22 | 'gt' => '>', 23 | 'ge' => '>=', 24 | 'lk' => 'LIKE', 25 | 'nl' => 'NOT LIKE', 26 | 'nu' => 'IS NULL', 27 | 'nn' => 'IS NOT NULL', 28 | ]; 29 | 30 | public function __construct(Options $options) 31 | { 32 | $this->options = $options; 33 | } 34 | 35 | /** 36 | * @param string[] $columns 37 | * @return string[] 38 | */ 39 | protected function escapeColumns(array $columns): array 40 | { 41 | return array_map($this->escapeIdentifier(...), $columns); 42 | } 43 | 44 | /** 45 | * Escapes a table or column name, and validates that it isn't blank 46 | */ 47 | public function escapeIdentifier(string $identifier): string 48 | { 49 | if ($identifier === '') { 50 | throw new \InvalidArgumentException('Identifier cannot be blank'); 51 | } 52 | 53 | $escaper = function (string $identifier): string { 54 | $c = $this->options->identifierQuote; 55 | return $c . str_replace($c, $c . $c, $identifier) . $c; 56 | }; 57 | 58 | $qualifiedIdentifiers = array_map($escaper, explode('.', $identifier)); 59 | return implode('.', $qualifiedIdentifiers); 60 | } 61 | 62 | /** 63 | * @throws \Exception if a column filter is empty 64 | * @param WhereClause $columnVals 65 | */ 66 | public function buildWhereClause(array $columnVals): SqlParams 67 | { 68 | if (!$columnVals) { 69 | return new SqlParams('', []); 70 | } 71 | 72 | $conditions = []; 73 | $params = []; 74 | 75 | foreach ($columnVals as $column => $value) { 76 | $column = $this->escapeIdentifier($column); 77 | 78 | if (is_array($value) && count($value) === 0) { 79 | throw new \Exception("Filter conditions cannot be empty for {$column} column"); 80 | } elseif (!is_array($value) || isset($value[0])) { 81 | // same as eq operator - handle below 82 | /** @var array $value */ 83 | $value = ['eq' => $value]; 84 | } 85 | 86 | foreach ($value as $shorthand => $val) { 87 | if (!isset(self::OPERATOR_MAP[$shorthand])) { 88 | throw new \Exception("{$shorthand} is not a valid operator"); 89 | } 90 | 91 | if ($val === null) { 92 | throw new \Exception('Filter values cannot be null'); 93 | } elseif ($shorthand === 'nu' || $shorthand === 'nn') { 94 | if ($val !== '') { 95 | throw new \Exception("{$shorthand} operator can only be used with a blank value"); 96 | } 97 | 98 | $conditions[] = $column . ' ' . self::OPERATOR_MAP[$shorthand]; 99 | } elseif (!is_array($val)) { 100 | $comparison = self::OPERATOR_MAP[$shorthand]; 101 | $conditions[] = "{$column} {$comparison} ?"; 102 | $params[] = $val; 103 | } elseif ($shorthand === 'eq' || $shorthand === 'ne') { 104 | // use IN(...) syntax 105 | $conditions[] = $column . ($shorthand === 'ne' ? ' NOT IN(' : ' IN(') 106 | . str_repeat('?,', count($val) - 1) . '?)'; 107 | $params = [...$params, ...$val]; 108 | } elseif ($shorthand === 'lk' || $shorthand === 'nl') { 109 | foreach ($val as $condition) { 110 | $conditions[] = $column . ' ' . self::OPERATOR_MAP[$shorthand] . ' ?'; 111 | $params[] = $condition; 112 | } 113 | } else { 114 | // it doesn't make sense to use greater than or less than operators with multiple values 115 | throw new \Exception("{$shorthand} operator cannot be used with an array"); 116 | } 117 | } 118 | } 119 | 120 | return new SqlParams(' WHERE ' . implode(' AND ', $conditions), $params); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/QueryBuilder/Select.php: -------------------------------------------------------------------------------- 1 | escapeColumns($orderBy)); 25 | } 26 | 27 | // [column1 => direction, column2 => direction, ...] 28 | foreach ($orderBy as $column => $direction) { 29 | $column = $this->escapeIdentifier($column); 30 | $sql .= $column; 31 | 32 | if ($direction === 'asc') { 33 | $sql .= ' ASC, '; 34 | } elseif ($direction === 'desc') { 35 | $sql .= ' DESC, '; 36 | } else { 37 | throw new \Exception("{$direction} is not a valid sort direction for column {$column}. Use asc or desc."); 38 | } 39 | } 40 | 41 | return substr_replace($sql, '', -2); // remove trailing comma and space 42 | } 43 | 44 | public function buildPagination(int $limit, int $offset): string 45 | { 46 | if ($this->options->fetchNextSyntax) { 47 | return "OFFSET {$offset} ROWS FETCH NEXT {$limit} ROWS ONLY"; 48 | } else { 49 | return "LIMIT {$limit} OFFSET {$offset}"; 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/QueryBuilder/Selector.php: -------------------------------------------------------------------------------- 1 | where !== []) { 33 | throw new \Exception('where method can only be called once'); 34 | } 35 | 36 | $this->where = $filter; 37 | return $this; 38 | } 39 | 40 | /** 41 | * @param mixed[] $sort 42 | * @throws \Exception if called more than once 43 | */ 44 | public function orderBy(array $sort): static 45 | { 46 | if ($this->orderBy !== []) { 47 | throw new \Exception('orderBy method can only be called once'); 48 | } 49 | 50 | foreach ($sort as $val) { 51 | if (!is_string($val)) { 52 | throw new \Exception('Invalid type for sort value: ' . get_debug_type($val)); 53 | } 54 | } 55 | 56 | /** @var string[] $sort */ 57 | $this->orderBy = $sort; 58 | return $this; 59 | } 60 | 61 | /** 62 | * @throws \Exception if a parameter is invalid 63 | */ 64 | public function offset(int $offset, int $limit): static 65 | { 66 | if ($limit < 1) { 67 | throw new \Exception('Limit must be greater than zero'); 68 | } elseif ($offset < 0) { 69 | throw new \Exception('Offset cannot be negative'); 70 | } 71 | 72 | $this->limit = $limit; 73 | $this->offset = $offset; 74 | return $this; 75 | } 76 | 77 | /** 78 | * @throws \Exception if attempting to paginate unordered rows 79 | */ 80 | public function getSqlParams(): SqlParams 81 | { 82 | $select = new Select($this->options); 83 | $where = $select->buildWhereClause($this->where); 84 | $orderBy = $select->buildOrderByClause($this->orderBy); 85 | $sql = $this->query->sql . $where->sql . $orderBy; 86 | 87 | if ($this->limit !== null && $this->offset !== null) { 88 | if ($this->orderBy === []) { 89 | throw new \Exception('Results must be sorted to use an offset'); 90 | } 91 | 92 | $sql .= ' ' . $select->buildPagination($this->limit, $this->offset); 93 | } 94 | 95 | return new SqlParams($sql, [...$this->query->params, ...$where->params]); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/QueryBuilder/SqlParams.php: -------------------------------------------------------------------------------- 1 | $params 12 | */ 13 | public function __construct( 14 | public readonly string $sql, 15 | public readonly array $params, 16 | ) {} 17 | } 18 | -------------------------------------------------------------------------------- /src/QueryBuilder/Update.php: -------------------------------------------------------------------------------- 1 | $value) { 28 | $sql .= $this->escapeIdentifier($column) . ' = ?, '; 29 | $params[] = $value; 30 | } 31 | 32 | $sql = substr_replace($sql, '', -2); // remove trailing comma 33 | $whereClause = $this->buildWhereClause($where); 34 | $sql .= $whereClause->sql; 35 | 36 | return new SqlParams($sql, array_merge($params, $whereClause->params)); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/QueryableSelector.php: -------------------------------------------------------------------------------- 1 | options); 14 | $this->peachySql = $peachySql; 15 | } 16 | 17 | public function query(): Statement 18 | { 19 | $sqlParams = $this->getSqlParams(); 20 | return $this->peachySql->query($sqlParams->sql, $sqlParams->params); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/SqlException.php: -------------------------------------------------------------------------------- 1 | sqlState = $sqlState; 23 | } 24 | 25 | /** 26 | * Returns the five character alphanumeric identifier defined in the ANSI SQL-92 standard 27 | */ 28 | public function getSqlState(): string 29 | { 30 | return $this->sqlState; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Statement.php: -------------------------------------------------------------------------------- 1 | stmt = $stmt; 26 | } 27 | 28 | /** 29 | * Executes the prepared statement 30 | */ 31 | public function execute(): void 32 | { 33 | if ($this->stmt === null) { 34 | throw new \Exception('Cannot execute closed statement'); 35 | } 36 | 37 | try { 38 | if (!$this->stmt->execute()) { 39 | /** @phpstan-ignore argument.type */ 40 | throw PeachySql::getError('Failed to execute prepared statement', $this->stmt->errorInfo()); 41 | } 42 | } catch (PDOException $e) { 43 | /** @phpstan-ignore argument.type */ 44 | throw PeachySql::getError('Failed to execute prepared statement', $this->stmt->errorInfo()); 45 | } 46 | 47 | $this->affected = 0; 48 | $multiRowset = $this->options->multiRowset; 49 | 50 | do { 51 | $this->affected += $this->stmt->rowCount(); 52 | $hasResultSet = $this->stmt->columnCount() !== 0; 53 | 54 | if ($hasResultSet) { 55 | break; // so that getIterator will be able to select the rows 56 | } 57 | } while ($multiRowset && $this->stmt->nextRowset()); 58 | 59 | if (!$this->usedPrepare && !$hasResultSet) { 60 | $this->close(); // no results, so statement can be closed 61 | } 62 | } 63 | 64 | /** 65 | * Returns an iterator which can be used to loop through each row in the result 66 | * @return \Generator 67 | */ 68 | public function getIterator(): \Generator 69 | { 70 | if ($this->stmt !== null) { 71 | while ($row = $this->stmt->fetch(PDO::FETCH_ASSOC)) { 72 | /** @phpstan-ignore generator.valueType */ 73 | yield $row; 74 | } 75 | 76 | if (!$this->usedPrepare) { 77 | $this->close(); 78 | } 79 | } 80 | } 81 | 82 | /** 83 | * Closes the prepared statement and deallocates the statement handle. 84 | * @throws \Exception if the statement has already been closed 85 | */ 86 | public function close(): void 87 | { 88 | if ($this->stmt === null) { 89 | throw new \Exception('Statement has already been closed'); 90 | } 91 | 92 | $this->stmt->closeCursor(); 93 | $this->stmt = null; 94 | } 95 | 96 | /** 97 | * Returns all rows selected by the query. 98 | * @return mixed[] 99 | */ 100 | public function getAll(): array 101 | { 102 | return iterator_to_array($this->getIterator()); 103 | } 104 | 105 | /** 106 | * Returns the first selected row, or null if zero rows were returned. 107 | * @return mixed[]|null 108 | */ 109 | public function getFirst(): ?array 110 | { 111 | $row = $this->getIterator()->current(); 112 | 113 | /** @phpstan-ignore notIdentical.alwaysTrue */ 114 | if ($row !== null) { 115 | $this->close(); // don't leave the SQL statement open 116 | } 117 | 118 | return $row; 119 | } 120 | 121 | /** 122 | * Returns the number of rows affected by the query 123 | */ 124 | public function getAffected(): int 125 | { 126 | return $this->affected; 127 | } 128 | } 129 | --------------------------------------------------------------------------------