├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json └── src ├── Concerns ├── ManagesDataDefinitions.php ├── ManagesMutations.php ├── ManagesPartitionedDml.php ├── ManagesSessionPool.php ├── ManagesSnapshots.php ├── ManagesTagging.php ├── ManagesTransactions.php ├── MarksAsNotSupported.php └── SharedGrammarCalls.php ├── Connection.php ├── Console ├── CooldownCommand.php ├── SessionsCommand.php └── WarmupCommand.php ├── Eloquent ├── Concerns │ └── InterleaveKeySupport.php └── Model.php ├── Events └── MutatingData.php ├── Query ├── ArrayValue.php ├── Builder.php ├── Concerns │ ├── SetsRequestTimeouts.php │ ├── UsesDataBoost.php │ ├── UsesFullTextSearch.php │ ├── UsesMutations.php │ ├── UsesPartitionedDml.php │ ├── UsesSnapshots.php │ └── UsesStaleReads.php ├── Grammar.php ├── IndexHint.php ├── Nested.php ├── Parameterizer.php └── Processor.php ├── Schema ├── Blueprint.php ├── Builder.php ├── ChangeStreamDefinition.php ├── ChangeStreamValueCaptureType.php ├── ColumnDefinition.php ├── Grammar.php ├── IndexDefinition.php ├── IntColumnDefinition.php ├── InterleaveDefinition.php ├── RenameDefinition.php ├── RowDeletionPolicyDefinition.php ├── SearchIndexDefinition.php ├── SequenceDefinition.php ├── TokenizerFunction.php └── UuidColumnDefinition.php ├── Session └── SessionInfo.php ├── SpannerServiceProvider.php └── TimestampBound ├── ExactStaleness.php ├── MaxStaleness.php ├── MinReadTimestamp.php ├── ReadTimestamp.php ├── StrongRead.php └── TimestampBoundInterface.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v8.3.1 (2025-01-07) 2 | 3 | - Fix Grammar::substituteBindingsIntoRawSql fails during unnesting (#246) 4 | 5 | # v8.3.0 (2024-12-25) 6 | 7 | - add support for full text search (#235) 8 | - add support for IDENTITY columns (#243) 9 | - consolidate schema options formatting (#241) 10 | - add support for invisible columns (#240) 11 | - add support for change streams using Blueprint (#230) 12 | - add support for snapshot queries (#215) 13 | - deprecate Connection::getDatabaseContext() and move logic to UseMutations::getMutationExecutor() (#227) 14 | - add support for `Query\Builder::whereNotInUnnest(...)` (#225) 15 | - `Query\Builder::whereIn` will now wrap values in `UNNEST` if the number of values exceeds the limit (950). (#226) 16 | - Commit options can now be set through config or through `Connection::setCommitOptions(...)` (#229) 17 | - improve PHPStan support (#244) 18 | 19 | # v8.2.0 (2024-08-05) 20 | 21 | > [!NOTE] 22 | > Minimum supported Laravel version is bumped to 11.15.0 for #224. 23 | 24 | - Fixed an issue where Schema changes were applied twice. (#224) 25 | 26 | # v8.1.3 (2024-06-24) 27 | 28 | Fixed 29 | - phpstan error for string('column', 'MAX') (#223) 30 | 31 | # v8.1.2 (2024-06-10) 32 | 33 | Fixed 34 | - updateOrInsert signature change in 11.10 (#216) 35 | 36 | # v8.1.1 (2024-06-03) 37 | 38 | Fixed 39 | - Timestamp bound queries were not applied when in transaction (#213) 40 | 41 | # v8.1.0 (2024-05-21) 42 | 43 | Added 44 | - Request and Transaction tagging support (#206) 45 | - Support for `INSERT OR IGNORE` (#207) 46 | - Support adding request timeout at query level (#208) 47 | 48 | Fixed 49 | - authCache needs namespace for each connection (#210) 50 | 51 | # v8.0.0 (2024-04-11) 52 | 53 | Added 54 | - Laravel 11 Support (#200) 55 | - The following deprecated methods have been removed 56 | - `Schema\Builder::getAllTables()` 57 | - `Schema\Builder::getIndexListing()` 58 | - `Schema\Grammar::compileTableExists()` 59 | - `Schema\Grammar::compileGetAllTables()` 60 | - `Schema\Grammar::compileColumnListing()` 61 | - `Schema\Grammar::compileIndexListing()` 62 | - `Query\Processor::processColumnListing()` 63 | - `Query\Processor::processIndexListing()` 64 | - `Blueprint::decimal()` can no longer specify `unsigned` flag. 65 | 66 | # v7.2.0 (2024-03-27) 67 | 68 | Added 69 | - Support for `Query\Builder::upsert()` (#203) 70 | 71 | # v7.1.0 (2024-03-11) 72 | 73 | Changed 74 | - `Schema\Builder::dropAllTables` returns immediately if no tables exist (#193) 75 | - Performance enhancements for `DB::pretend` statements, no longer incurring the overhead of creating transactions (#191) 76 | - Set the `$defaultMorphKeyType` in `Schema\Builder` to `uuid` (#192) 77 | 78 | Fixed 79 | - `Connection::escapeString` now properly escapes backslashes (#197) 80 | 81 | # v7.0.0 (2024-02-21) 82 | 83 | Added 84 | - `json` `mediumText` `longText` `char` support for `Schema\Builder` (#155) (#158) 85 | - `Schema\Grammar::compileDropForeign` to allow dropping foreign key constraints (#163) 86 | - `Schema\Builder::dropAllTables` works properly, dropping foreign keys, indexes, then tables in order of interleaving (#161) 87 | - Support for inserting and selecting array of DateTime/Numeric objects (#168) 88 | - Allow pretending for DDL statements (#170) 89 | - Allow `spanner_emulator.disable_query_null_filtered_index_check` to be set (#180) 90 | - Allow default max transaction attempts to be changed (#179) 91 | - Table prefixing support (#172) 92 | - Support for `GENERATE_UUID()` in migrations (#174) 93 | - Support `Schema\Blueprint::increments()` by using a column of type `STRING(36)` with `DEFAULT (GENERATE_UUID())` (#175) 94 | - Support for `CREATE SEQUENCE` in migrations (#181) 95 | 96 | Changed 97 | - **[Breaking]** Timestamps no longer respect the date format specified in Grammar (#168) 98 | - `Query\Builder::lock()` no longer throw an error and will be ignored instead (#156) 99 | - `Schema\Builder::getIndexListing()` `Schema\Grammar::compileIndexListing()` converted to `getIndexes()` and `compileIndexes()` to align with standard Laravel methods (#161) 100 | - Missing primary key will no longer be checked and will be checked on the server side instead (#177) 101 | - **[Breaking]** `Connection::runDdl()` and `Connection::runDdls()` has been removed. Use `Connection::runDdlBatch()` instead. (#178) 102 | - **[Breaking]** Trait `ManagesStaleReads` has been removed (which contained `cursorWithTimestampBound()` and `selectWithTimestampBound()`. Use `selectWithOptions()` instead). (#178) 103 | - **[Breaking]** `Blueprint::interleave()` and `IndexDefinition::interleave()` now throw an error instead of a deprecation. (#178) 104 | - **[Breaking]** `Connection::transaction()`'s `$attempts` argument's default value was changed from 10 to -1 (which is a magic number for default value which is 11) (#179) 105 | - **[Breaking]** All upper case functions and casting `DATE` `TIMESTAMP` `CURRENT_TIMESTAMP()` has been changed to lower case for consistency. (#182) 106 | 107 | Fixed 108 | - `Schema\Grammar::compileAdd()` `Schema\Grammar::compileChange()` now create separate statements (#159) 109 | - `Connection::runDdlBatch()` with empty statement now return an empty array instead of throwing an error (#169) 110 | 111 | # v6.1.2 (2024-01-16) 112 | 113 | Fixed 114 | - Fixed an error when rolling back a transaction that did not execute begin (#166) 115 | 116 | # v6.1.1 (2023-12-11) 117 | 118 | Fixed 119 | - Bug where auth and session pool writing to the same file may cause race condition (#152) 120 | 121 | # v6.1.0 (2023-11-29) 122 | 123 | Added 124 | - Add support for [NUMERIC](https://cloud.google.com/spanner/docs/reference/standard-sql/data-types#numeric_type) column type. (#145) 125 | 126 | Fixed 127 | - Match internals so that it lines up with laravel 10.34.0. (#150) 128 | 129 | # v6.0.0 (2023-11-22) 130 | 131 | Added 132 | - Add [Data Boost](https://cloud.google.com/spanner/docs/databoost/databoost-overview) support (#131) 133 | - Deprecation warnings to `Connection`'s methods `cursorWithTimestampBound` `selectWithTimestampBound` `selectOneWithTimestampBound`. Use `cursorWithOptions` `selectWithOptions` instead. (#122) 134 | - `Connection` has new methods `selectWithOptions` `cursorWithOptions` which allows spanner specific options to be set for each query. (#122) 135 | - `session:list` command can now show and filter by labels. (#134) 136 | - Allow custom cache path (#142) 137 | 138 | Changed 139 | - [Breaking] Match `Query\Builder::forceIndex()` behavior with laravel's (`forceIndex` property no longer exists). (#114) 140 | - [Breaking] SessionNotFoundErrorMode was removed and will always run clear session pool. (#132) (#130) 141 | - [Breaking] Auth cache and Session pool now share the same file cache adapter (#139) 142 | 143 | # v5.3.0 (2023-11-17) 144 | 145 | Fixed 146 | - Explicitly stage/clear transaction on commit to correctly run afterCommit jobs in Laravel >= [v10.32.0](https://github.com/laravel/framework/pull/48859) (#144) 147 | 148 | # v5.2.2 (2023-08-22) 149 | 150 | Fixed 151 | - Fixed a case where queries were not being retried on "Session Not Found" errors when session pool is undefined (#129) 152 | 153 | # v5.2.1 (2023-08-16) 154 | 155 | Fixed 156 | - Escape list for `Query/Builder::toRawSql` (#127) 157 | 158 | # v5.2.0 159 | 160 | Added 161 | - Added deprecation warnings to `Connection::runDdl` and `Connection::runDdls` (#98) 162 | - Added `ManagesMutations::insertOrUpdateUsingMutation` and `UsesMutations::insertOrUpdateUsingMutation` to do upserts (#109) 163 | - Added Support for `Schema\Builder::dropIfExists()` (#115) 164 | - Added support for adding row deletion policy when modifying table (#124) 165 | - Added Support for `Query\Builder::toRawSql()` (#123) 166 | 167 | Changed 168 | - `Connection::waitForOperation` and `Connection::isDoneOperation` has been removed. (#99) 169 | - Update `export-ignore` entries in `.gitattributes` (#104) 170 | - Use abstract definitions on traits instead of relying on `@methods` `@property`. (#120) 171 | - Stop using `call_user_func` (#121) 172 | 173 | Fixed 174 | - Transaction state was not being cleared if rolled back failed. (#107) 175 | - Column was not escaped for clause `REPLACE ROW DELETION POLICY` (#125) 176 | 177 | # v5.1.0 178 | 179 | Added 180 | - Added `Connection::runDdlBatch` which runs DDLs in batch synchronously. (#86) 181 | - Added emulator support for `Connection::listSessions`. (#88) 182 | - Added `Schema\Grammar::typeDouble` for better compatibility. (#97) 183 | 184 | Fixed 185 | - Fixed bug where running `Connection::statement` with DDLs was not logging and was not triggering events. (#86) 186 | - FilesystemAdapter was not creating the directory for the cache file with proper permissions. (#93) 187 | 188 | Changed 189 | - Use google-cloud-php's CacheSessionPool since the [concerned bug](https://github.com/googleapis/google-cloud-php/issues/5567) has been fixed in [v1.53](https://github.com/googleapis/google-cloud-php-spanner/releases/tag/v1.58.2). (#90) 190 | - Separate session pool and authentication per connection so transaction works properly. (#89) 191 | - SessionPool and AuthCache now writes to `storage/framework/spanner/{$name}-{auth|session}`. (#93) 192 | 193 | # v5.0.0 194 | 195 | updated composer.json to only support laravel 10 196 | 197 | Fixed 198 | - `Connection::reconnectIfMissingConnection` was changed from `protected` to `public` to match laravel 10. (#77) 199 | - [Query/Expression](https://laravel.com/docs/10.x/upgrade#database-expressions) changed from `(string)$expr` to `$expr->getValue($grammar)`. (#77) 200 | - Applied [QueryException constructor change](https://laravel.com/docs/10.x/upgrade#query-exception-constructor) to `Schema/Grammar`. (#77) 201 | 202 | Changed 203 | - Checks that primary key is defined in schema and throws an exception if not defined. (#58) 204 | - `Colopl\Spanner\Session` has been renamed to `Colopl\Spanner\SessionInfo`. 205 | - `Blueprint::stringArray`'s `$length` parameter is now optional and defaults to `255`. 206 | - Auth and session pool no longer use the custom FileCacheAdapter and uses Symfony's FilesystemAdapter instead. (#63) 207 | - Path for auth and session pool files have moved from `storage/framework/cache/spanner` to `storage/framework/spanner/{auth|session}`. (#63) 208 | - Default Session Not Found Error Mode was changed from `MAINTAIN_SESSION_POOL` to `CLEAR_SESSION_POOL` (wasn't fully confident at the time, but I think it should be safe to assume it's working now). 209 | - Schema\Builder::getAllTables() now returns rows with `name` and `type` fields instead of list of strings (was implemented incorrectly). (#73) 210 | - Exception previously thrown in `Query/Builder` for `sharedLock`, `lockForUpdate`, `insertGetId` was moved to `Query/Grammar`. (#76) 211 | - Query/Builder::lock will now throw `BadMethodCallException` if called. Was ignored in previous versions. (#76) 212 | - [Breaking Change] Commands are now only avaiable in cli mode (#81) 213 | - Connections will now be closed after every job has been processed in the queue. (#80) 214 | 215 | Refactored 216 | - Rollback handling has been refactored to better readability. (#79) 217 | 218 | # v4.7.0 219 | 220 | Added 221 | - Support `Blueprint::text` (translates to `STRING(MAX)`). 222 | 223 | Chore 224 | - Removed `ramsey/uuid` from composer.json since laravel already includes it. 225 | 226 | Fixed 227 | - Expressions given as $value in `Schema\Grammar::formatDefaultValue` will now go through `getValue` to match upstream (No behavioral change). 228 | 229 | # v4.6.0 230 | 231 | Fixed 232 | - `Model::fresh` and `Model::refresh` now adds interleaved keys to the query. 233 | - Remove Type declaration from `Query/Builder::forceIndex()` to match the one added to laravel/framework in `v9.52.0`. 234 | 235 | # v4.5.0 236 | 237 | Added 238 | - Command `spanner:warmup` now has a new option `--skip-on-error` which will skip any connections which throws an exception. 239 | 240 | Fixed 241 | - Transaction state was not cleared if a NotFoundException was raised during rollback. 242 | 243 | # v4.4.0 244 | 245 | Added 246 | - Support Schema\Builder::getAllTables() 247 | - Command `spanner:cooldown` which clears all connections in the session pool. 248 | - Command `spanner:warmup` now has a new option `--refresh` which will clear all existing sessions before warming up. 249 | - Command `spanner:sessions` now has a new option `--sort` and `--order` which allows for sorting of results. 250 | 251 | Changed 252 | - Default SessionPool was changed from `Google\Cloud\Spanner\Session\CacheSessionPool` to `Colopl\Spanner\Session\CacheSessionPool` to patch an [unresolved issue on Google's end](https://github.com/googleapis/google-cloud-php/issues/5567). 253 | 254 | Fixed 255 | - SessionPool was not cleared if php terminated immediately after calling `CacheSessionPool::clear`. 256 | 257 | # v4.3.0 258 | 259 | Added 260 | - Support for default values in table columns. 261 | - Command `spanner:sessions` which will list the sessions on the server side. 262 | - Command `spanner:warmup` which warms up sessions upto minimum number set in config. 263 | - `TransactionCommitting` support has been added (NOTE: this is triggered only once at root on nested transactions). 264 | - Replace and drop row deletion policy methods for Schema Builder. 265 | - Action classes for interleave and index for IDE auto-completion. 266 | - `Blueprint::interleaveInParent()` was added and `Blueprint::interleave()` has been deprecated. 267 | - `IndexDefinition::interleaveIn()` was added and `IndexDefinition::interleave()` has been deprecated. 268 | 269 | Fixed 270 | - Array Column's type now gets parsed in `Schema/Grammar` instead of at blueprint. 271 | 272 | Chore 273 | - Unnecessary folder depth has been flattened. 274 | 275 | # v4.2.0 276 | 277 | Added 278 | - `Colopl\Spanner\Query\Builder::truncate()` is now implemented. (use to throw an error) (a171e90d3b862a3b207582ab44f0d4009e12118c) 279 | 280 | Fixed 281 | - `Colopl\Spanner\Schema\Grammar::getDateFormat()` now returns `'Y-m-d\TH:i:s.uP'` instead of the default which does work correctly in Cloud Spanner. (43bffe630dbf765019674d12bf3ff3e768fe4022) 282 | - `Colopl\Spanner\Schema\Grammar::wrapValue()` has been extracted as trait, so it can be shared with `Colopl\Spanner\Query\Grammar`. (2f4347942397b9197284f342e2a345bceec12402, ca8faa1db70934fd2809e00fbd640dd711e996f2) 283 | - `Colopl\Spanner\Concerns\ManagesTransactions::handleBeginTransactionException()` now matches return type of parent. (7c9b5c305ab4b7e192e58af84aaa5b78caaebcb5) 284 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2019 Colopl Inc. All Rights Reserved. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | laravel-spanner 2 | ================ 3 | 4 | Laravel database driver for Google Cloud Spanner 5 | 6 | [![License](https://img.shields.io/packagist/l/colopl/laravel-spanner.svg?style=flat-square)](https://github.com/colopl/laravel-spanner/blob/master/LICENSE) 7 | [![Latest Stable Version](https://img.shields.io/packagist/v/colopl/laravel-spanner.svg?style=flat-square)](https://packagist.org/packages/colopl/laravel-spanner) 8 | [![Minimum PHP Version](https://img.shields.io/packagist/php-v/colopl/laravel-spanner.svg?style=flat-square)](https://secure.php.net/) 9 | 10 | ## Requirements 11 | 12 | - PHP >= 8.2 13 | - Laravel >= 11 14 | - [gRPC extension](https://cloud.google.com/php/grpc) 15 | - [protobuf extension](https://cloud.google.com/php/grpc#install_the_protobuf_runtime_library) (recommended for better performance) 16 | - `sysvmsg`, `sysvsem`, `sysvshm` extensions (recommended for better performance) 17 | 18 | ## Installation 19 | Put JSON credential file path to env variable: `GOOGLE_APPLICATION_CREDENTIALS` 20 | 21 | ``` 22 | export GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json 23 | ``` 24 | 25 | Install via composer 26 | 27 | ```sh 28 | composer require colopl/laravel-spanner 29 | ``` 30 | 31 | Add connection config to `config/database.php` 32 | 33 | ```php 34 | [ 35 | 'connections' => [ 36 | 'spanner' => [ 37 | 'driver' => 'spanner', 38 | 'instance' => '', 39 | 'database' => '', 40 | ] 41 | ] 42 | ]; 43 | ``` 44 | 45 | That's all. You can use database connection as usual. 46 | 47 | ```php 48 | $conn = DB::connection('spanner'); 49 | $conn->... 50 | ``` 51 | 52 | ## Additional Configurations 53 | You can pass `SpannerClient` config and `CacheSessionPool` options as below. 54 | For more information, please see [Google Client Library docs](http://googleapis.github.io/google-cloud-php/#/docs/google-cloud/latest/spanner/spannerclient?method=__construct) 55 | 56 | ```php 57 | [ 58 | 'connections' => [ 59 | 'spanner' => [ 60 | 'driver' => 'spanner', 61 | 'instance' => '', 62 | 'database' => '', 63 | 64 | // Spanner Client configurations 65 | 'client' => [ 66 | 'projectId' => 'xxx', 67 | ... 68 | ], 69 | 70 | // CacheSessionPool options 71 | 'session_pool' => [ 72 | 'minSessions' => 10, 73 | 'maxSessions' => 500, 74 | ], 75 | ] 76 | ] 77 | ]; 78 | ``` 79 | 80 | ## Recommended Setup 81 | 82 | Please note that the following are not required, but are strongly recommended for better performance. 83 | 84 | - Install `protobuf` pecl extension for faster network communication. 85 | - Install `sysvmsg`, `sysvsem`, `sysvshm` extensions for faster session management. 86 | - Mount the cache directory (`./storage/framework/spanner` by default) to tmpfs for better session io performance. 87 | Cache path can be changed by setting `connections.{name}.cache_path` in your `config/database.php` file. 88 | 89 | ## Unsupported features 90 | 91 | - STRUCT data types 92 | - Inserting/Updating JSON data types 93 | 94 | ## Limitations 95 | 96 | ### SQL Mode 97 | Currently only supports Spanner running GoogleSQL (PostgreSQL mode is not supported). 98 | 99 | ### Query 100 | - [Binding more than 950 parameters in a single query will result in an error](https://cloud.google.com/spanner/quotas#query-limits) 101 | by the server. In order to by-pass this limitation, this driver will attempt to switch to using `Query\Builder::whereInUnnest(...)` 102 | internally when the passed parameter exceeds the limit set by `parameter_unnest_threshold` config (default: `900`). 103 | You can turn this feature off by setting the value to `false`. 104 | 105 | ### Eloquent 106 | If you use interleaved keys, you MUST define them in the `interleaveKeys` property, or else you won't be able to save. 107 | For more detailed instructions, see `Colopl\Spanner\Tests\Eloquent\ModelTest`. 108 | 109 | ## Additional Information 110 | 111 | ### Migrations 112 | 113 | Since Spanner recommends using UUID as a primary key, `Blueprint::increments` (and all of its variants) will create a 114 | column of type `STRING(36) DEFAULT (GENERATE_UUID())` to generate and fill the column with a UUID 115 | and flag it as a primary key. If you want to use `AUTO_INCREMENT`, you can do so by specifying it directly like this: 116 | 117 | ```php 118 | // `default_sequence_kind` must be set in order to use auto increment 119 | $schemaBuilder->setDatabaseOptions([ 120 | 'default_sequence_kind' => 'bit_reversed_positive', 121 | ]); 122 | 123 | $schemaBuilder->create('user', function (Blueprint $table) { 124 | $table->integer('id')->primary()->autoIncrement(); 125 | }); 126 | ``` 127 | 128 | ### Transactions 129 | Google Cloud Spanner sometimes requests transaction retries (e.g. `UNAVAILABLE`, and `ABORTED`), even if the logic is correct. For that reason, please do not manage transactions manually. 130 | 131 | You should always use the `transaction` method which handles retry requests internally. 132 | 133 | ```php 134 | // BAD: Do not use transactions manually!! 135 | try { 136 | DB::beginTransaction(); 137 | ... 138 | DB::commit(); 139 | } catch (\Throwable $ex) { 140 | DB::rollBack(); 141 | } 142 | 143 | // GOOD: You should always use transaction method 144 | DB::transaction(function() { 145 | ... 146 | }); 147 | ``` 148 | 149 | Google Cloud Spanner creates transactions for all data operations even if you do not explicitly create transactions. 150 | 151 | In particular, in the SELECT statement, the type of transaction varies depending on whether it is explicit or implicit. 152 | 153 | ```php 154 | // implicit transaction (Read-only transaction) 155 | $conn->select('SELECT ...'); 156 | 157 | // explicit transaction (Read-write transaction) 158 | $conn->transaction(function() { 159 | $conn->select('SELECT ...'); 160 | }); 161 | 162 | // implicit transaction (Read-write transaction) 163 | $conn->insert('INSERT ...'); 164 | 165 | // explicit transaction (Read-write transaction) 166 | $conn->transaction(function() { 167 | $conn->insert('INSERT ...'); 168 | }); 169 | ``` 170 | 171 | | Transaction type | **SELECT** statement | **INSERT/UPDATE/DELETE** statement | 172 | | :--- | :--- | :--- | 173 | | implicit transaction | **Read-only** transaction with **singleUse** option | **Read-write** transaction with **singleUse** option | 174 | | explicit transaction | **Read-write** transaction | **Read-write** transaction | 175 | 176 | For more information, see [Cloud Spanner Documentation about transactions](https://cloud.google.com/spanner/docs/transactions) 177 | 178 | ### Stale reads 179 | 180 | You can use [Stale reads (timestamp bounds)](https://cloud.google.com/spanner/docs/timestamp-bounds) as below. 181 | 182 | ```php 183 | // There are four types of timestamp bounds: ExactStaleness, MaxStaleness, MinReadTimestamp and ReadTimestamp. 184 | $timestampBound = new ExactStaleness(10); 185 | 186 | // by Connection 187 | $connection->selectWithTimestampBound('SELECT ...', $bindings, $timestampBound); 188 | 189 | // by Query Builder 190 | $queryBuilder 191 | ->withStaleness($timestampBound) 192 | ->get(); 193 | ``` 194 | 195 | Stale reads always runs as read-only transaction with `singleUse` option. So you can not run as read-write transaction. 196 | 197 | ### Snapshot reads 198 | 199 | You can use explicit Snapshot reads, either on `Connection`, or on `Model` or `Builder` instances. When running `snapshot()` on `Connection`, you pass a `Closure` that you can use to run multiple reads from within the same Snapshot. 200 | 201 | ```php 202 | $timestampBound = new ExactStaleness(10); 203 | 204 | // by Connection 205 | $connection->snapshot($timestampBound, function() use ($connection) { 206 | $result1 = $connection->table('foo')->get(); 207 | $result2 = $connection->table('bar')->get(); 208 | 209 | return [$result1, $result2]; 210 | ); 211 | 212 | // by Model 213 | User::where('foo', 'bar') 214 | ->snapshot($timestampBound) 215 | ->get(); 216 | 217 | // by Query Builder 218 | $queryBuilder 219 | ->snapshot($timestampBound) 220 | ->get(); 221 | ``` 222 | 223 | ### Data Boost 224 | 225 | Data boost creates snapshot and runs the query in parallel without affecting existing workloads. 226 | 227 | You can read more about it [here](https://cloud.google.com/spanner/docs/databoost/databoost-overview). 228 | 229 | Below are some examples of how to use it. 230 | 231 | ```php 232 | // Using Connection 233 | $connection->selectWithOptions('SELECT ...', $bindings, ['dataBoostEnabled' => true]); 234 | 235 | // Using Query Builder 236 | $queryBuilder 237 | ->useDataBoost() 238 | ->setRequestTimeoutSeconds(60) 239 | ->get(); 240 | ``` 241 | 242 | > [!NOTE] 243 | > This creates a new session in the background which is not shared with the current session pool. 244 | > This means, queries running with data boost will not be associated with transactions that may be taking place. 245 | 246 | ### Request Tags and Transaction Tags 247 | 248 | Spanner allows you to attach tags to your queries and transactions that can be [used for troubleshooting](https://cloud.google.com/spanner/docs/introspection/troubleshooting-with-tags). 249 | 250 | You can set request tags and transaction tags as below. 251 | 252 | ```php 253 | $requestPath = request()->path(); 254 | $tag = 'url=' . $requestPath; 255 | $connection->setRequestTag($tag); 256 | $connection->setTransactionTag($tag); 257 | ``` 258 | 259 | ### Data Types 260 | 261 | Some data types of Google Cloud Spanner does not have corresponding built-in type of PHP. 262 | You can use following classes by [Google Cloud PHP Client](https://github.com/googleapis/google-cloud-php) 263 | 264 | - BYTES: `Google\Cloud\Spanner\Bytes` 265 | - DATE: `Google\Cloud\Spanner\Date` 266 | - NUMERIC: `Google\Cloud\Spanner\Numeric` 267 | - TIMESTAMP: `Google\Cloud\Spanner\Timestamp` 268 | 269 | When fetching rows, the library coverts the following column types 270 | - `Timestamp` -> [Carbon](https://laravel.com/api/10.x/Illuminate/Support/Carbon.html) with the default timezone in PHP 271 | - `Numeric` -> `string` 272 | 273 | Note that if you execute a query without QueryBuilder, it will not have these conversions. 274 | 275 | 276 | ### Partitioned DML 277 | You can run partitioned DML as below. 278 | 279 | ```php 280 | // by Connection 281 | $connection->runPartitionedDml('UPDATE ...'); 282 | 283 | 284 | // by Query Builder 285 | $queryBuilder->partitionedUpdate($values); 286 | $queryBuilder->partitionedDelete(); 287 | ``` 288 | 289 | However, Partitioned DML has some limitations. See [Cloud Spanner Documentation about Partitioned DML](https://cloud.google.com/spanner/docs/dml-partitioned#dml_and_partitioned_dml) for more information. 290 | 291 | 292 | ### Interleave 293 | You can define [interleaved tables](https://cloud.google.com/spanner/docs/schema-and-data-model#creating_a_hierarchy_of_interleaved_tables) as below. 294 | 295 | ```php 296 | $schemaBuilder->create('user_items', function (Blueprint $table) { 297 | $table->uuid('user_id'); 298 | $table->uuid('id'); 299 | $table->uuid('item_id'); 300 | $table->integer('count'); 301 | $table->timestamps(); 302 | 303 | $table->primary(['user_id', 'id']); 304 | 305 | // interleaved table 306 | $table->interleaveInParent('users')->cascadeOnDelete(); 307 | 308 | // interleaved index 309 | $table->index(['userId', 'created_at'])->interleaveIn('users'); 310 | }); 311 | ``` 312 | 313 | ### Row Deletion Policy 314 | 315 | You can define [row deletion policy](https://cloud.google.com/spanner/docs/ttl/working-with-ttl) as below. 316 | 317 | ```php 318 | $schemaBuilder->create('user', function (Blueprint $table) { 319 | $table->uuid('user_id'); 320 | $table->timestamps(); 321 | 322 | // create a policy 323 | $table->deleteRowsOlderThan(['updated_at'], 365); 324 | }); 325 | 326 | $schemaBuilder->table('user', function (Blueprint $table) { 327 | // add policy 328 | $table->addRowDeletionPolicy('udpated_at', 100); 329 | 330 | // replace policy 331 | $table->replaceRowDeletionPolicy('udpated_at', 100); 332 | 333 | // drop policy 334 | $table->dropRowDeletionPolicy(); 335 | }); 336 | ``` 337 | 338 | ### Sequence 339 | 340 | If you want a simple sequence to be used as a primary key, you can use `useSequence()` method. 341 | If `useSequence()` is called without providing a `$name`, a sequence with name `user_id_sequence` will be created 342 | with `start_with_counter` set with a random value between 1 and 1,000,000. 343 | 344 | ```php 345 | $schemaBuilder->create('user', function (Blueprint $table) { 346 | $table->integer('id')->useSequence(); 347 | }); 348 | ``` 349 | 350 | If you want more flexibility, you can also create, alter, and drop sequences directly as below. 351 | 352 | ```php 353 | $schemaBuilder->create('user_items', function (Blueprint $table) { 354 | $table->createSequence('sequence_name'); 355 | $table->integer('id')->useSequence('sequence_name'); 356 | 357 | $table->alterSequence('sequence_name') 358 | ->startWithCounter(100) 359 | ->skipRangeMin(1) 360 | ->skipRangeMax(10); 361 | 362 | $table->dropSequence('sequence_name'); 363 | }); 364 | ``` 365 | 366 | ### Change Streams 367 | 368 | Spanner supports [Change Streams](https://cloud.google.com/spanner/docs/change-streams) which allows you to listen to changes in the database. 369 | Change streams can be created/altered/dropped through the schema builder as shown below. 370 | 371 | ```php 372 | $schemaBuilder->create('user_items', function (Blueprint $table) { 373 | $table->createChangeStream('stream_name') 374 | ->for('user_items', ['userId', 'userItemId']) 375 | ->retentionPeriod('7d') 376 | ->valueCaptureType(ChangeStreamValueCaptureType::NewValues) 377 | ->excludeTtlDeletes(true); 378 | 379 | $table->createChangeStream('stream_name') 380 | ->excludeInsert(true) 381 | ->excludeUpdate(true) 382 | ->excludeDelete(true); 383 | 384 | $table->dropChangeStream('stream_name'); 385 | }); 386 | ``` 387 | 388 | ### Full Text Search 389 | 390 | Spanner supports [Full Text Search](https://cloud.google.com/spanner/docs/full-text-search) which allows you to search for text in columns. 391 | 392 | You can define a token list column and a search index for the column as below. 393 | 394 | ```php 395 | $schemaBuilder->create('user', function (Blueprint $table) { 396 | $table->uuid('id')->primary(); 397 | $table->string('name'); 398 | // adds an invisible column for full text search 399 | $table->tokenList('UserNameTokens', TokenizerFunction::FullText, 'name', ['language_tag' => 'en']); 400 | 401 | // adds a SEARCH INDEX 402 | $table->fullText(['UserNameTokens']); 403 | }); 404 | ``` 405 | 406 | Once the schema has been applied, you can use the search methods in the query builder to search for text in the columns as below. 407 | 408 | ```php 409 | User::query()->searchFullText('UserNameTokens', 'John OR Kevin', ['enhance_query' => true])->get(); 410 | ``` 411 | 412 | The methods available are `searchFullText`, `searchSubstring`, and `searchNgrams`. 413 | 414 | ### Secondary Index Options 415 | 416 | You can define Spanner specific index options like [null filtering](https://cloud.google.com/spanner/docs/secondary-indexes#null-indexing-disable) and [storing](https://cloud.google.com/spanner/docs/secondary-indexes#storing-clause) as below. 417 | 418 | ```php 419 | $schemaBuilder->table('user_items', function (Blueprint $table) { 420 | $table->index('userId') 421 | // Interleave in parent table 422 | ->interleaveIn('user') 423 | // Add null filtering 424 | ->nullFiltered() 425 | // Add storing 426 | ->storing(['itemId', 'count']); 427 | }); 428 | ``` 429 | 430 | ### Mutations 431 | 432 | You can [insert, update, and delete data using mutations](https://cloud.google.com/spanner/docs/modify-mutation-api) to modify data instead of using DML to improve performance. 433 | 434 | ``` 435 | $queryBuilder->insertUsingMutation($values); 436 | $queryBuilder->updateUsingMutation($values); 437 | $queryBuilder->insertOrUpdateUsingMutation($values); 438 | $queryBuilder->deleteUsingMutation($values); 439 | ``` 440 | 441 | Please note that mutation api does not work the same way as DML. 442 | All mutations calls within a transaction are queued and sent as batch at the time you commit. 443 | This means that if you make any modifications through the above functions and then try to SELECT the same records before committing, the returned results will not include any of the modifications you've made inside the transaction. 444 | 445 | 446 | ### SessionPool and AuthCache 447 | 448 | In order to improve the performance of the first connection per request, we use [AuthCache](https://github.com/googleapis/google-cloud-php#caching-access-tokens) and [CacheSessionPool](https://googleapis.github.io/google-cloud-php/#/docs/google-cloud/latest/spanner/session/cachesessionpool). 449 | 450 | By default, this library uses [Filesystem Cache Adapter](https://symfony.com/doc/current/components/cache/adapters/filesystem_adapter.html) as the caching pool. If you want to use your own caching pool, you can extend ServiceProvider and inject it into the constructor of `Colopl\Spanner\Connection`. 451 | 452 | The initialization of each session takes about a second, so warming up the sessions during the boot up phase of your 453 | server is recommended. This can be achieved by running the `php artisan spanner:warmup` command. You can set the number 454 | of sessions to warm up by setting the `connections.{name}.session_pool.maxSessions` option in `config/database.php` 455 | 456 | Similarly, the sessions remain active for 60 minutes after use so deleting the sessions during the shutdown phase 457 | of your server is recommended. This can be achieved by running the `php artisan spanner:cooldown` command. 458 | 459 | ### Queue Worker 460 | 461 | After every job is processed, the connection will be disconnected so the session can be released into the session pool. 462 | This allows the session to be renewed (through `maintainSessionPool()`) or expire. 463 | 464 | 465 | ### Laravel Tinker 466 | You can use [Laravel Tinker](https://github.com/laravel/tinker) with commands such as `php artisan tinker`. 467 | But your session may hang when accessing Cloud Spanner. This is known gRPC issue that occurs when PHP forks a process. 468 | The workaround is to add following line to `php.ini`. 469 | 470 | ```ini 471 | grpc.enable_fork_support=1 472 | ``` 473 | 474 | 475 | 476 | ## Development 477 | 478 | ### Testing 479 | You can run tests on docker by the following command. Note that some environment variables must be set. 480 | In order to set the variables, rename [.env.sample](./.env.sample) to `.env` and edit the values of the 481 | defined variables. 482 | 483 | | Name | Value | 484 | | :-- | :-- | 485 | | `GOOGLE_APPLICATION_CREDENTIALS` | The path of the service account key file with access privilege to Google Cloud Spanner instance | 486 | | `DB_SPANNER_INSTANCE_ID` | Instance ID of your Google Cloud Spanner | 487 | | `DB_SPANNER_DATABASE_ID` | Name of the database with in the Google Cloud Spanner instance | 488 | | `DB_SPANNER_PROJECT_ID` | Not required if your credential includes the project ID | 489 | 490 | ```sh 491 | make test 492 | ``` 493 | 494 | ## License 495 | Apache 2.0 - See [LICENSE](./LICENSE) for more information. 496 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "colopl/laravel-spanner", 3 | "description": "Laravel database driver for Google Cloud Spanner", 4 | "type": "library", 5 | "license": "Apache-2.0", 6 | "authors": [ 7 | {"name": "Hiroki Awata", "email": "deactivated@colopl.co.jp"}, 8 | {"name": "Takayasu Oyama", "email": "t-oyama@colopl.co.jp"} 9 | ], 10 | "require": { 11 | "php": "^8.2", 12 | "ext-grpc": "*", 13 | "ext-json": "*", 14 | "laravel/framework": "^12.0", 15 | "google/cloud-spanner": "^1.58.4", 16 | "grpc/grpc": "^1.42", 17 | "symfony/cache": "~7", 18 | "symfony/lock": "~7" 19 | }, 20 | "require-dev": { 21 | "orchestra/testbench": "~10", 22 | "phpunit/phpunit": "~11.0", 23 | "phpstan/phpstan": "^2" 24 | }, 25 | "autoload": { 26 | "psr-4": { 27 | "Colopl\\Spanner\\": "src" 28 | } 29 | }, 30 | "autoload-dev": { 31 | "psr-4": { 32 | "Colopl\\Spanner\\Tests\\": "tests" 33 | } 34 | }, 35 | "extra": { 36 | "laravel": { 37 | "providers": [ 38 | "Colopl\\Spanner\\SpannerServiceProvider" 39 | ] 40 | } 41 | }, 42 | "scripts": { 43 | "analyze": "phpstan analyse --configuration phpstan.neon --memory-limit=-1", 44 | "coverage": "phpunit --coverage-html=coverage", 45 | "test": "phpunit" 46 | }, 47 | "suggest": { 48 | "ext-protobuf": "Native support for protobuf is available. Will use pure PHP implementation if not present.", 49 | "ext-sysvmsg": "Can use SemaphoreLock for session handling. Will use FileLock if not present.", 50 | "ext-sysvsem": "Can use SemaphoreLock for session handling. Will use FileLock if not present.", 51 | "ext-sysvshm": "Can use SemaphoreLock for session handling. Will use FileLock if not present." 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Concerns/ManagesDataDefinitions.php: -------------------------------------------------------------------------------- 1 | $statements 34 | * @return mixed 35 | */ 36 | public function runDdlBatch(array $statements): mixed 37 | { 38 | if (count($statements) === 0) { 39 | return []; 40 | } 41 | 42 | $start = microtime(true); 43 | $result = []; 44 | 45 | if (!$this->pretending()) { 46 | $result = $this->waitForOperation( 47 | $this->getSpannerDatabase()->updateDdlBatch($statements), 48 | ); 49 | } 50 | 51 | foreach ($statements as $statement) { 52 | $this->logQuery($statement, [], $this->getElapsedTime($start)); 53 | } 54 | 55 | return $result; 56 | } 57 | 58 | /** 59 | * @param string[] $statements Additional DDL statements 60 | * @return void 61 | */ 62 | public function createDatabase(array $statements = []) 63 | { 64 | $start = microtime(true); 65 | 66 | $this->waitForOperation( 67 | $this->getSpannerDatabase()->create(['statements' => $statements]), 68 | ); 69 | 70 | foreach ($statements as $statement) { 71 | $this->logQuery($statement, [], $this->getElapsedTime($start)); 72 | } 73 | } 74 | 75 | /** 76 | * @return void 77 | */ 78 | public function dropDatabase() 79 | { 80 | $this->getSpannerDatabase()->drop(); 81 | } 82 | 83 | /** 84 | * @return bool 85 | */ 86 | public function databaseExists() 87 | { 88 | return $this->getSpannerDatabase()->exists(); 89 | } 90 | 91 | /** 92 | * @param LongRunningOperation $operation 93 | * @return mixed 94 | */ 95 | protected function waitForOperation(LongRunningOperation $operation): mixed 96 | { 97 | $result = $operation->pollUntilComplete(['maxPollingDurationSeconds' => 0.0]); 98 | if ($operation->error() !== null) { 99 | throw new RuntimeException((string)json_encode($operation->error())); 100 | } 101 | return $result; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Concerns/ManagesMutations.php: -------------------------------------------------------------------------------- 1 | >|array 33 | */ 34 | trait ManagesMutations 35 | { 36 | /** 37 | * @return Database|Transaction 38 | */ 39 | abstract protected function getDatabaseContext(): Database|Transaction; 40 | 41 | /** 42 | * @param string $table 43 | * @param TDataSet $dataSet 44 | * @return void 45 | */ 46 | public function insertUsingMutation(string $table, array $dataSet) 47 | { 48 | $this->withTransactionEvents(function () use ($table, $dataSet) { 49 | $dataSet = $this->prepareForMutation($dataSet); 50 | $this->event(new MutatingData($this, $table, 'insert', $dataSet)); 51 | $this->getMutationExecutor()->insertBatch($table, $dataSet); 52 | }); 53 | } 54 | 55 | /** 56 | * @param string $table 57 | * @param TDataSet $dataSet 58 | * @return void 59 | */ 60 | public function updateUsingMutation(string $table, array $dataSet) 61 | { 62 | $this->withTransactionEvents(function () use ($table, $dataSet) { 63 | $dataSet = $this->prepareForMutation($dataSet); 64 | $this->event(new MutatingData($this, $table, 'update', $dataSet)); 65 | $this->getMutationExecutor()->updateBatch($table, $dataSet); 66 | }); 67 | } 68 | 69 | /** 70 | * @param string $table 71 | * @param TDataSet $dataSet 72 | * @return void 73 | */ 74 | public function insertOrUpdateUsingMutation(string $table, array $dataSet) 75 | { 76 | $this->withTransactionEvents(function () use ($table, $dataSet) { 77 | $dataSet = $this->prepareForMutation($dataSet); 78 | $this->event(new MutatingData($this, $table, 'update', $dataSet)); 79 | $this->getMutationExecutor()->insertOrUpdateBatch($table, $dataSet); 80 | }); 81 | } 82 | 83 | /** 84 | * @param string $table 85 | * @param scalar|array|KeySet $keySet 86 | * @return void 87 | */ 88 | public function deleteUsingMutation(string $table, $keySet) 89 | { 90 | $this->withTransactionEvents(function () use ($table, $keySet) { 91 | $keySet = $this->createDeleteMutationKeySet($keySet); 92 | $dataSet = $keySet->keys() ?: $keySet->keySetObject(); 93 | $this->event(new MutatingData($this, $table, 'delete', $dataSet)); 94 | $this->getMutationExecutor()->delete($table, $keySet); 95 | }); 96 | } 97 | 98 | /** 99 | * @return Database|Transaction 100 | */ 101 | protected function getMutationExecutor(): Database|Transaction 102 | { 103 | return $this->getCurrentTransaction() ?? $this->getSpannerDatabase(); 104 | } 105 | 106 | /** 107 | * @param callable $mutationCall 108 | * @return void 109 | */ 110 | protected function withTransactionEvents(callable $mutationCall) 111 | { 112 | // events not necessary since it is already called 113 | if ($this->inTransaction()) { 114 | $mutationCall(); 115 | } else { 116 | $this->event(new TransactionBeginning($this)); 117 | $mutationCall(); 118 | $this->event(new TransactionCommitted($this)); 119 | } 120 | } 121 | 122 | /** 123 | * @param TDataSet $dataSet 124 | * @return array> 125 | */ 126 | protected function prepareForMutation(array $dataSet): array 127 | { 128 | if (empty($dataSet)) { 129 | return []; 130 | } 131 | 132 | if (!array_is_list($dataSet)) { 133 | $dataSet = [$dataSet]; 134 | } 135 | 136 | foreach ($dataSet as $index => $values) { 137 | foreach ($values as $name => $value) { 138 | if ($value instanceof DateTimeInterface) { 139 | $dataSet[$index][$name] = new Timestamp($value); 140 | } 141 | } 142 | } 143 | 144 | return $dataSet; 145 | } 146 | 147 | /** 148 | * @param mixed|list|KeySet $keys 149 | * @return KeySet 150 | */ 151 | protected function createDeleteMutationKeySet($keys) 152 | { 153 | if ($keys instanceof KeySet) { 154 | return $keys; 155 | } 156 | 157 | if (is_object($keys)) { 158 | throw new InvalidArgumentException('delete should contain array of keys or be instance of KeySet. ' . get_class($keys) . ' given.'); 159 | } 160 | 161 | if (!is_array($keys)) { 162 | $keys = [$keys]; 163 | } 164 | 165 | return new KeySet(['keys' => $keys]); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/Concerns/ManagesPartitionedDml.php: -------------------------------------------------------------------------------- 1 | $bindings 34 | * @return int 35 | */ 36 | public function runPartitionedDml($query, $bindings = []) 37 | { 38 | /** @var int */ 39 | return $this->run($query, $bindings, function ($query, $bindings) { 40 | if ($this->pretending()) { 41 | return 0; 42 | } 43 | 44 | $rowCount = $this->getSpannerDatabase()->executePartitionedUpdate($query, ['parameters' => $this->prepareBindings($bindings)]); 45 | 46 | $this->recordsHaveBeenModified($rowCount > 0); 47 | 48 | return $rowCount; 49 | }); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Concerns/ManagesSessionPool.php: -------------------------------------------------------------------------------- 1 | getSpannerDatabase()->sessionPool(); 46 | $sessionPool?->clear(); 47 | } 48 | 49 | /** 50 | * @return bool 51 | */ 52 | public function maintainSessionPool(): bool 53 | { 54 | $sessionPool = $this->getSpannerDatabase()->sessionPool(); 55 | if ($sessionPool !== null && method_exists($sessionPool, 'maintain')) { 56 | $sessionPool->maintain(); 57 | return true; 58 | } 59 | return false; 60 | } 61 | 62 | /** 63 | * @return int Number of warmed up sessions 64 | */ 65 | public function warmupSessionPool(): int 66 | { 67 | $sessionPool = $this->getSpannerDatabase()->sessionPool(); 68 | if ($sessionPool !== null && method_exists($sessionPool, 'warmup')) { 69 | return $sessionPool->warmup(); 70 | } 71 | return 0; 72 | } 73 | 74 | /** 75 | * @return Collection 76 | */ 77 | public function listSessions(): Collection 78 | { 79 | $databaseName = $this->getSpannerDatabase()->name(); 80 | 81 | $emulatorHost = getenv('SPANNER_EMULATOR_HOST'); 82 | $config = $emulatorHost 83 | ? $this->emulatorGapicConfig($emulatorHost) 84 | : []; 85 | 86 | $response = (new ProtobufSpannerClient($config))->listSessions($databaseName); 87 | 88 | $sessions = []; 89 | foreach ($response->iterateAllElements() as $session) { 90 | assert($session instanceof ProtobufSpannerSession); 91 | $sessions[] = new SessionInfo($session); 92 | } 93 | return new Collection($sessions); 94 | } 95 | 96 | /** 97 | * @return array 98 | * @throws ReflectionException 99 | */ 100 | public function __debugInfo() 101 | { 102 | // ------------------------------------------------------------------------- 103 | // HACK: Use reflection to extract some information from a private method 104 | // ------------------------------------------------------------------------- 105 | $session = null; 106 | $credentialFetcher = null; 107 | 108 | $internalConnectionProperty = (new ReflectionObject($this->getSpannerClient()))->getProperty('connection'); 109 | $internalConnectionProperty->setAccessible(true); 110 | $internalConnection = $internalConnectionProperty->getValue($this->spannerClient); 111 | if ($internalConnection instanceof Grpc) { 112 | $requestWrapper = $internalConnection->requestWrapper(); 113 | $credentialFetcher = $requestWrapper?->getCredentialsFetcher(); 114 | } 115 | 116 | $spannerDatabase = $this->spannerDatabase; 117 | if ($spannerDatabase !== null) { 118 | $sessionProperty = (new ReflectionObject($spannerDatabase))->getProperty('session'); 119 | $sessionProperty->setAccessible(true); 120 | $session = $sessionProperty->getValue($spannerDatabase); 121 | assert($session instanceof CloudSpannerSession); 122 | } 123 | 124 | return [ 125 | 'identity' => $spannerDatabase?->identity(), 126 | 'session' => $session?->name(), 127 | 'sessionPool' => $spannerDatabase?->sessionPool(), 128 | 'credentialFetcher' => $credentialFetcher, 129 | ]; 130 | } 131 | 132 | } 133 | -------------------------------------------------------------------------------- /src/Concerns/ManagesSnapshots.php: -------------------------------------------------------------------------------- 1 | currentSnapshot !== null) { 41 | throw new LogicException('Nested snapshots are not supported.'); 42 | } 43 | 44 | $options = $timestampBound->transactionOptions(); 45 | try { 46 | $this->currentSnapshot = $this->getSpannerDatabase()->snapshot($options); 47 | return $callback(); 48 | } finally { 49 | $this->currentSnapshot = null; 50 | } 51 | } 52 | 53 | /** 54 | * @return bool 55 | */ 56 | public function inSnapshot(): bool 57 | { 58 | return $this->currentSnapshot !== null; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Concerns/ManagesTagging.php: -------------------------------------------------------------------------------- 1 | requestTag = $tag; 39 | return $this; 40 | } 41 | 42 | /** 43 | * @return string|null 44 | */ 45 | public function getRequestTag(): ?string 46 | { 47 | return $this->requestTag; 48 | } 49 | 50 | /** 51 | * @param string|null $tag 52 | * @return $this 53 | */ 54 | public function setTransactionTag(?string $tag): static 55 | { 56 | $this->transactionTag = $tag; 57 | return $this; 58 | } 59 | 60 | /** 61 | * @return string|null 62 | */ 63 | public function getTransactionTag(): ?string 64 | { 65 | return $this->transactionTag; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Concerns/ManagesTransactions.php: -------------------------------------------------------------------------------- 1 | |null $commitOptions 44 | */ 45 | protected ?array $commitOptions = null; 46 | 47 | /** 48 | * @inheritDoc 49 | * @template T 50 | * @param Closure(static): T $callback 51 | * @param int $attempts -1 is used as a magic number to indicate the default value 52 | * @return T 53 | */ 54 | public function transaction(Closure $callback, $attempts = -1) 55 | { 56 | // -1 is used as a magic number to indicate the default value defined in Database::MAX_RETRIES (+1) 57 | // So, we need to resolve the actual value here. 58 | if ($attempts === -1) { 59 | $attempts = $this->getDefaultMaxTransactionAttempts(); 60 | } 61 | 62 | // Since Cloud Spanner does not support nested transactions, 63 | // we use Laravel's transaction management for nested transactions only. 64 | if ($this->transactions > 0) { 65 | return parent::transaction($callback, $attempts); 66 | } 67 | 68 | $options = ['maxRetries' => $attempts - 1]; 69 | 70 | $tag = $this->getTransactionTag(); 71 | if ($tag !== null) { 72 | $options['tag'] = $tag; 73 | } 74 | 75 | return $this->withSessionNotFoundHandling(function () use ($callback, $options) { 76 | $return = $this->getSpannerDatabase()->runTransaction(function (Transaction $tx) use ($callback) { 77 | try { 78 | if ($this->inSnapshot()) { 79 | throw new LogicException('Calling transaction() inside a snapshot is not supported.'); 80 | } 81 | 82 | $this->currentTransaction = $tx; 83 | 84 | $this->transactions++; 85 | 86 | $this->transactionsManager?->begin( 87 | /** 88 | * TODO: throws error 89 | * @phpstan-ignore argument.type 90 | */ 91 | $this->getName(), $this->transactions, 92 | ); 93 | 94 | $this->fireConnectionEvent('beganTransaction'); 95 | 96 | $result = $callback($this); 97 | 98 | $this->performSpannerCommit(); 99 | 100 | return $result; 101 | } catch (Throwable $e) { 102 | $this->rollBack(); 103 | throw $e; 104 | } 105 | }, $options); 106 | 107 | $this->fireConnectionEvent('committed'); 108 | 109 | return $return; 110 | }); 111 | } 112 | 113 | /** 114 | * @return Transaction|null 115 | * @internal 116 | */ 117 | public function getCurrentTransaction() 118 | { 119 | return $this->currentTransaction; 120 | } 121 | 122 | /** 123 | * @inheritDoc 124 | */ 125 | protected function createTransaction() 126 | { 127 | if ($this->transactions === 0) { 128 | try { 129 | $this->reconnectIfMissingConnection(); 130 | $this->currentTransaction = $this->getSpannerDatabase()->transaction(); 131 | } catch (Exception $e) { 132 | $this->handleBeginTransactionException($e); 133 | } 134 | } 135 | } 136 | 137 | /** 138 | * @inheritDoc 139 | */ 140 | protected function handleBeginTransactionException($e) 141 | { 142 | if ($this->causedByLostConnection($e)) { 143 | $this->reconnect(); 144 | 145 | $this->currentTransaction = $this->getSpannerDatabase()->transaction(); 146 | return; 147 | } 148 | 149 | throw $e; 150 | } 151 | 152 | /** 153 | * @inheritDoc 154 | * @deprecated Use self::transaction() instead 155 | */ 156 | public function commit() 157 | { 158 | $this->performSpannerCommit(); 159 | $this->fireConnectionEvent('committed'); 160 | } 161 | 162 | /** 163 | * @return void 164 | * @throws AbortedException 165 | */ 166 | protected function performSpannerCommit(): void 167 | { 168 | if ($this->transactions === 1 && $this->currentTransaction !== null) { 169 | $this->fireConnectionEvent('committing'); 170 | $this->currentTransaction->commit($this->getCommitOptions()); 171 | } 172 | 173 | [$levelBeingCommitted, $this->transactions] = [ 174 | $this->transactions, 175 | max(0, $this->transactions - 1), 176 | ]; 177 | 178 | if ($this->isTransactionFinished()) { 179 | $this->currentTransaction = null; 180 | } 181 | 182 | $this->transactionsManager?->commit( 183 | /** 184 | * TODO: throws error 185 | * @phpstan-ignore argument.type 186 | */ 187 | $this->getName(), 188 | $levelBeingCommitted, 189 | $this->transactions, 190 | ); 191 | } 192 | 193 | /** 194 | * @inheritDoc 195 | */ 196 | protected function performRollBack($toLevel) 197 | { 198 | if ($toLevel !== 0) { 199 | return; 200 | } 201 | 202 | if ($this->currentTransaction !== null) { 203 | try { 204 | if ($this->currentTransaction->state() === Transaction::STATE_ACTIVE && $this->currentTransaction->id() !== null) { 205 | $this->currentTransaction->rollBack(); 206 | } 207 | } finally { 208 | $this->currentTransaction = null; 209 | } 210 | } 211 | } 212 | 213 | /** 214 | * @return bool 215 | */ 216 | public function inTransaction() 217 | { 218 | return $this->currentTransaction !== null; 219 | } 220 | 221 | /** 222 | * @return bool 223 | */ 224 | protected function isTransactionFinished() 225 | { 226 | return $this->inTransaction() && $this->transactions === 0; 227 | } 228 | 229 | /** 230 | * Taken and modified from the original ManagesTransactions trait. 231 | * Unlike MySQL all error cases including deadlocks are thrown as 232 | * AbortedException so causedByDeadlock will not be called here. 233 | * 234 | * @inheritDoc 235 | */ 236 | protected function handleTransactionException($e, $currentAttempt, $maxAttempts) 237 | { 238 | if ($this->transactions > 1) { 239 | $this->transactions--; 240 | 241 | throw $e; 242 | } 243 | 244 | $this->rollBack(); 245 | 246 | throw $e; 247 | } 248 | 249 | /** 250 | * @param Throwable $e 251 | * @return void 252 | */ 253 | protected function handleRollbackException(Throwable $e) 254 | { 255 | // Must be reset so that transaction can be retried. 256 | // otherwise, transactions will remain at 1. 257 | $this->transactions = 0; 258 | 259 | throw $e; 260 | } 261 | 262 | /** 263 | * @return int 264 | */ 265 | public function getDefaultMaxTransactionAttempts(): int 266 | { 267 | return $this->maxAttempts ??= (Database::MAX_RETRIES + 1); 268 | } 269 | 270 | /** 271 | * @param int $attempts 272 | * @return $this 273 | */ 274 | public function setDefaultMaxTransactionAttempts(int $attempts): static 275 | { 276 | $this->maxAttempts = $attempts; 277 | return $this; 278 | } 279 | 280 | /** 281 | * @return array 282 | */ 283 | public function getCommitOptions(): array 284 | { 285 | if ($this->commitOptions !== null) { 286 | return $this->commitOptions; 287 | } 288 | 289 | $options = $this->getConfig('commit') ?? []; 290 | assert(is_array($options)); 291 | /** @var array $options */ 292 | return $this->commitOptions = $options; 293 | } 294 | 295 | /** 296 | * @param array $options 297 | * @return void 298 | */ 299 | public function setCommitOptions(array $options): void 300 | { 301 | $this->commitOptions = $options; 302 | } 303 | } 304 | -------------------------------------------------------------------------------- /src/Concerns/MarksAsNotSupported.php: -------------------------------------------------------------------------------- 1 | $options 50 | * @param string $delimiter 51 | * @return string 52 | */ 53 | protected function formatOptions(array $options, string $delimiter = '='): string 54 | { 55 | $mapped = Arr::map($options, function (int|float|bool|string|BackedEnum $v, string $k) use ($delimiter): string { 56 | return Str::snake($k) . $delimiter . $this->formatOptionValue($v); 57 | }); 58 | return implode(', ', $mapped); 59 | } 60 | 61 | /** 62 | * @param scalar|BackedEnum $value 63 | * @return string 64 | */ 65 | protected function formatOptionValue(mixed $value): string 66 | { 67 | return match (true) { 68 | is_bool($value) => $value ? 'true' : 'false', 69 | is_string($value) => $this->quoteString($value), 70 | $value instanceof BackedEnum => $this->formatOptionValue($value->value), 71 | default => (string) $value, 72 | }; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Connection.php: -------------------------------------------------------------------------------- 1 | $config 100 | * @param CacheItemPoolInterface|null $authCache 101 | * @param SessionPoolInterface|null $sessionPool 102 | */ 103 | public function __construct( 104 | string $instanceId, 105 | string $database, 106 | $tablePrefix = '', 107 | array $config = [], 108 | ?CacheItemPoolInterface $authCache = null, 109 | ?SessionPoolInterface $sessionPool = null, 110 | ) { 111 | $this->instanceId = $instanceId; 112 | $this->authCache = $authCache; 113 | $this->sessionPool = $sessionPool; 114 | parent::__construct( 115 | // TODO: throw error after v9 116 | static fn() => null, 117 | $database, 118 | $tablePrefix, 119 | $config, 120 | ); 121 | } 122 | 123 | /** 124 | * @return SpannerClient 125 | * @throws GoogleException 126 | */ 127 | protected function getSpannerClient() 128 | { 129 | if ($this->spannerClient === null) { 130 | $clientConfig = $this->config['client'] ?? []; 131 | if ($this->authCache !== null) { 132 | $clientConfig = array_merge($clientConfig, ['authCache' => $this->authCache]); 133 | } 134 | $this->spannerClient = new SpannerClient($clientConfig); 135 | } 136 | return $this->spannerClient; 137 | } 138 | 139 | /** 140 | * @return Database 141 | */ 142 | public function getSpannerDatabase(): Database 143 | { 144 | $this->reconnectIfMissingConnection(); 145 | return $this->spannerDatabase ?? throw new LogicException('Spanner Database does not exist'); 146 | } 147 | 148 | /** 149 | * @deprecated will be removed in v10 150 | * @return Database|Transaction 151 | */ 152 | protected function getDatabaseContext(): Database|Transaction 153 | { 154 | return $this->getCurrentTransaction() ?? $this->getSpannerDatabase(); 155 | } 156 | 157 | /** 158 | * @return bool 159 | */ 160 | public function isConnected(): bool 161 | { 162 | return $this->spannerDatabase !== null; 163 | } 164 | 165 | /** 166 | * @inheritDoc 167 | */ 168 | public function reconnect() 169 | { 170 | $this->disconnect(); 171 | $connectOptions = []; 172 | if ($this->sessionPool !== null) { 173 | $connectOptions = array_merge($connectOptions, ['sessionPool' => $this->sessionPool]); 174 | } 175 | $this->spannerDatabase = $this->getSpannerClient()->connect($this->instanceId, $this->database, $connectOptions); 176 | } 177 | 178 | /** 179 | * @inheritDoc 180 | */ 181 | public function reconnectIfMissingConnection() 182 | { 183 | if ($this->spannerDatabase === null) { 184 | $this->reconnect(); 185 | } 186 | } 187 | 188 | /** 189 | * @inheritDoc 190 | */ 191 | public function disconnect() 192 | { 193 | if ($this->spannerDatabase !== null) { 194 | $this->spannerDatabase->close(); 195 | $this->spannerDatabase = null; 196 | } 197 | } 198 | 199 | /** 200 | * @inheritDoc 201 | * @return QueryGrammar 202 | */ 203 | protected function getDefaultQueryGrammar(): QueryGrammar 204 | { 205 | return new QueryGrammar($this); 206 | } 207 | 208 | /** 209 | * @inheritDoc 210 | * @return SchemaGrammar 211 | */ 212 | protected function getDefaultSchemaGrammar(): SchemaGrammar 213 | { 214 | return new SchemaGrammar($this); 215 | } 216 | 217 | /** 218 | * @inheritDoc 219 | * @return SchemaBuilder 220 | */ 221 | public function getSchemaBuilder() 222 | { 223 | if ($this->schemaGrammar === null) { 224 | $this->useDefaultSchemaGrammar(); 225 | } 226 | 227 | return new SchemaBuilder($this); 228 | } 229 | 230 | /** 231 | * @inheritDoc 232 | * @return QueryProcessor 233 | */ 234 | protected function getDefaultPostProcessor(): QueryProcessor 235 | { 236 | return new QueryProcessor(); 237 | } 238 | 239 | /** 240 | * OVERRIDDEN for return type change 241 | * 242 | * {@inheritDoc} 243 | * 244 | * @param Closure|QueryBuilder|Expression|string $table 245 | * @return QueryBuilder 246 | */ 247 | public function table($table, $as = null): QueryBuilder 248 | { 249 | return $this->query()->from($table, $as); 250 | } 251 | 252 | /** 253 | * OVERRIDDEN for return type change 254 | * 255 | * {@inheritDoc} 256 | * 257 | * @return QueryBuilder 258 | */ 259 | public function query(): QueryBuilder 260 | { 261 | return new QueryBuilder($this, $this->getQueryGrammar(), $this->getPostProcessor()); 262 | } 263 | 264 | /** 265 | * {@inheritDoc} 266 | * @param array $bindings 267 | * @return array 268 | */ 269 | public function select($query, $bindings = [], $useReadPdo = true): array 270 | { 271 | return $this->selectWithOptions($query, $bindings, []); 272 | } 273 | 274 | /** 275 | * {@inheritDoc} 276 | * @return Generator> 277 | * @param array $bindings 278 | * @return Generator> 279 | * @phpstan-ignore method.childReturnType 280 | */ 281 | public function cursor($query, $bindings = [], $useReadPdo = true): Generator 282 | { 283 | return $this->cursorWithOptions($query, $bindings, []); 284 | } 285 | 286 | /** 287 | * @param string $query 288 | * @param array $bindings 289 | * @param array $options 290 | * @return array> 291 | */ 292 | public function selectWithOptions(string $query, array $bindings, array $options): array 293 | { 294 | /** @var array> */ 295 | return $this->run($query, $bindings, function ($query, $bindings) use ($options): array { 296 | return !$this->pretending() 297 | ? iterator_to_array($this->executeQuery($query, $bindings, $options)) 298 | : []; 299 | }); 300 | } 301 | 302 | /** 303 | * @param string $query 304 | * @param array $bindings 305 | * @param array $options 306 | * @return Generator> 307 | */ 308 | public function cursorWithOptions(string $query, array $bindings, array $options): Generator 309 | { 310 | return $this->run($query, $bindings, function ($query, $bindings) use ($options): Generator { 311 | return !$this->pretending() 312 | ? $this->executeQuery($query, $bindings, $options) 313 | : (static fn() => yield from [])(); 314 | }); 315 | } 316 | 317 | /** 318 | * {@inheritDoc} 319 | * @param array $bindings 320 | */ 321 | public function statement($query, $bindings = []): bool 322 | { 323 | // is SELECT query 324 | if (0 === stripos(ltrim($query), 'select')) { 325 | $this->select($query, $bindings); 326 | return true; 327 | } 328 | 329 | // is DML query 330 | if (0 === stripos(ltrim($query), 'insert') || 331 | 0 === stripos(ltrim($query), 'update') || 332 | 0 === stripos(ltrim($query), 'delete')) { 333 | $this->affectingStatement($query, $bindings); 334 | return true; 335 | } 336 | 337 | // is DDL Query 338 | return $this->runDdlBatch([$query]) !== null; 339 | } 340 | 341 | /** 342 | * {@inheritDoc} 343 | * @param array $bindings 344 | */ 345 | public function affectingStatement($query, $bindings = []): int 346 | { 347 | /** @var Closure(): int $runQueryCall */ 348 | $runQueryCall = function () use ($query, $bindings) { 349 | return $this->run($query, $bindings, function ($query, $bindings) { 350 | if ($this->pretending()) { 351 | return 0; 352 | } 353 | 354 | $transaction = $this->getCurrentTransaction(); 355 | 356 | if ($transaction === null) { 357 | throw new RuntimeException('Tried to run update outside of transaction! Affecting statements must be done inside a transaction'); 358 | } 359 | 360 | return $this->shouldRunAsBatchDml($query) 361 | ? $this->executeBatchDml($transaction, $query, $bindings) 362 | : $this->executeDml($transaction, $query, $bindings); 363 | }); 364 | }; 365 | 366 | if ($this->pretending()) { 367 | return $runQueryCall(); 368 | } 369 | 370 | if ($this->inTransaction()) { 371 | return $runQueryCall(); 372 | } 373 | 374 | // Create a temporary transaction for single affecting statement 375 | return $this->transaction($runQueryCall); 376 | } 377 | 378 | /** 379 | * @inheritDoc 380 | */ 381 | public function unprepared($query): bool 382 | { 383 | return $this->statement($query); 384 | } 385 | 386 | /** 387 | * @inheritDoc 388 | */ 389 | public function getDatabaseName() 390 | { 391 | return $this->getSpannerDatabase()->name(); 392 | } 393 | 394 | /** 395 | * @internal 396 | * {@inheritDoc} 397 | * @return never 398 | */ 399 | public function setDatabaseName($database) 400 | { 401 | $this->markAsNotSupported('setDatabaseName'); 402 | } 403 | 404 | /** 405 | * @internal 406 | * {@inheritDoc} 407 | * @return never 408 | */ 409 | public function getPdo() 410 | { 411 | $this->markAsNotSupported('PDO access'); 412 | } 413 | 414 | /** 415 | * @internal 416 | * {@inheritDoc} 417 | * @return never 418 | */ 419 | public function getReadPdo() 420 | { 421 | $this->markAsNotSupported('PDO access'); 422 | } 423 | 424 | /** 425 | * {@inheritDoc} 426 | * @param array $bindings 427 | * @return array 428 | */ 429 | public function prepareBindings(array $bindings) 430 | { 431 | $grammar = $this->getQueryGrammar(); 432 | 433 | foreach ($bindings as $key => $value) { 434 | $bindings[$key] = $this->prepareBinding($grammar, $value); 435 | } 436 | 437 | return $bindings; 438 | } 439 | 440 | protected function prepareBinding(BaseQueryGrammar $grammar, mixed $value): mixed 441 | { 442 | if ($value instanceof Arrayable) { 443 | $value = $value->toArray(); 444 | } 445 | 446 | // We need to transform all instances of DateTimeInterface into the actual 447 | // date string. Each query grammar maintains its own date string format 448 | // so we'll just ask the grammar for the format to get from the date. 449 | if ($value instanceof DateTimeInterface) { 450 | // Since Timestamp::__toString calls setTimezone() on the DateTime object, 451 | // we need to clone the DateTime object to avoid changing the original object. 452 | return ($value instanceof DateTimeImmutable) 453 | ? new Timestamp($value) 454 | : new Timestamp(DateTimeImmutable::createFromInterface($value)); 455 | } 456 | 457 | if (is_array($value)) { 458 | $arr = []; 459 | foreach ($value as $k => $v) { 460 | $arr[$k] = $this->prepareBinding($grammar, $v); 461 | } 462 | return $arr; 463 | } 464 | 465 | return $value; 466 | } 467 | 468 | /** 469 | * {@inheritDoc} 470 | * @param scalar|list|Nested|null $value 471 | */ 472 | public function escape($value, $binary = false) 473 | { 474 | if ($value instanceof Nested) { 475 | $value = $value->toArray(); 476 | } 477 | 478 | return is_array($value) 479 | ? $this->escapeArray($value, $binary) 480 | : parent::escape($value, $binary); 481 | } 482 | 483 | /** 484 | * @param array $value 485 | * @param bool $binary 486 | * @return string 487 | */ 488 | protected function escapeArray(array $value, bool $binary): string 489 | { 490 | if (array_is_list($value)) { 491 | $escaped = array_map(function (mixed $v) use ($binary): string { 492 | return is_scalar($v) 493 | ? $this->escape($v, $binary) 494 | : throw new LogicException('Nested arrays are not supported by Cloud Spanner'); 495 | }, $value); 496 | return '[' . implode(', ', $escaped) . ']'; 497 | } 498 | throw new LogicException('Associative arrays are not supported'); 499 | } 500 | 501 | /** 502 | * @inheritDoc 503 | */ 504 | protected function escapeBool($value) 505 | { 506 | return $value ? 'true' : 'false'; 507 | } 508 | 509 | /** 510 | * @inheritDoc 511 | */ 512 | protected function escapeString($value) 513 | { 514 | return str_contains($value, "\n") 515 | ? 'r"""' . addcslashes($value, '"\\') . '"""' 516 | : '"' . addcslashes($value, '"\\') . '"'; 517 | } 518 | 519 | /** 520 | * {@inheritDoc} 521 | * @param array $bindings 522 | */ 523 | protected function runQueryCallback($query, $bindings, Closure $callback) 524 | { 525 | $this->parameterizer = $this->parameterizer ?? new QueryParameterizer(); 526 | [$query, $bindings] = $this->parameterizer->parameterizeQuery($query, $bindings); 527 | 528 | try { 529 | $result = $this->withSessionNotFoundHandling(function () use ($query, $bindings, $callback) { 530 | return $callback($query, $bindings); 531 | }); 532 | } 533 | 534 | // AbortedExceptions are expected to be thrown upstream by the Google Client Library upstream, 535 | // so AbortedExceptions will not be wrapped with QueryException. 536 | catch (AbortedException $e) { 537 | throw $e; 538 | } 539 | 540 | // If an exception occurs when attempting to run a query, we'll format the error 541 | // message to include the bindings with SQL, which will make this exception a 542 | // lot more helpful to the developer instead of just the database's errors. 543 | catch (Exception $e) { 544 | throw new QueryException( 545 | $this->getName() ?? 'unknown', 546 | $query, 547 | $this->prepareBindings($bindings), 548 | $e, 549 | ); 550 | } 551 | 552 | return $result; 553 | } 554 | 555 | /** 556 | * Retry on "session not found" errors 557 | * 558 | * @see https://cloud.google.com/spanner/docs/sessions#handle_deleted_sessions 559 | * 560 | * > Attempts to use a deleted session result in NOT_FOUND. 561 | * > If you encounter this error, create and use a new session, add the new session to the pool, 562 | * > and remove the deleted session from the pool. 563 | * 564 | * Most cases are covered by Google's library except for the following two cases. 565 | * 566 | * - When a connection is opened, and idles for more than 1 hour. 567 | * - If a user manually deletes a session from the console. 568 | * 569 | * The document states that the library should be handling this, and library for Go and Java 570 | * handles this within the library but PHP's does not. So unfortunately, this code has to exist. 571 | * 572 | * We asked the maintainers of the PHP library to handle it, but they refused. 573 | * https://github.com/googleapis/google-cloud-php/issues/6284. 574 | * 575 | * @template T 576 | * @param Closure(): T $callback 577 | * @return T 578 | * @throws AbortedException|NotFoundException|InvalidArgumentException 579 | */ 580 | protected function withSessionNotFoundHandling(Closure $callback): mixed 581 | { 582 | try { 583 | return $callback(); 584 | } catch (Throwable $e) { 585 | if (!$this->inTransaction() && $this->causedBySessionNotFound($e)) { 586 | return $this->handleSessionNotFoundException($callback); 587 | } 588 | throw $e; 589 | } 590 | } 591 | 592 | /** 593 | * @param string $query 594 | * @param array $bindings 595 | * @param array $options 596 | * @return Generator> 597 | */ 598 | protected function executeQuery(string $query, array $bindings, array $options): Generator 599 | { 600 | $options += ['parameters' => $this->prepareBindings($bindings)]; 601 | 602 | if (isset($options['dataBoostEnabled'])) { 603 | return $this->executePartitionedQuery($query, $options); 604 | } 605 | 606 | $tag = $this->getRequestTag(); 607 | if ($tag !== null) { 608 | $options['requestOptions'] ??= []; 609 | assert(is_array($options['requestOptions'])); 610 | $options['requestOptions']['requestTag'] = $tag; 611 | } 612 | 613 | if (isset($options['snapshotEnabled'])) { 614 | $timestamp = $options['snapshotTimestampBound']; 615 | assert($timestamp instanceof TimestampBoundInterface); 616 | return $this->snapshot($timestamp, fn() => $this->executeSnapshotQuery($query, $options)); 617 | } 618 | 619 | if ($this->inSnapshot()) { 620 | return $this->executeSnapshotQuery($query, $options); 621 | } 622 | 623 | if ($this->canExecuteAsReadWriteTransaction($options) && $transaction = $this->getCurrentTransaction()) { 624 | return $transaction->execute($query, $options)->rows(); 625 | } 626 | 627 | return $this->getSpannerDatabase()->execute($query, $options)->rows(); 628 | } 629 | 630 | /** 631 | * @param string $query 632 | * @param array $options 633 | * @return Generator> 634 | */ 635 | protected function executePartitionedQuery(string $query, array $options): Generator 636 | { 637 | $snapshot = $this->getSpannerClient() 638 | ->batch($this->instanceId, $this->database, $options) 639 | ->snapshot(); 640 | 641 | foreach ($snapshot->partitionQuery($query, $options) as $partition) { 642 | foreach ($snapshot->executePartition($partition) as $row) { 643 | /** @var array $row */ 644 | yield $row; 645 | } 646 | } 647 | } 648 | 649 | /** 650 | * @param string $query 651 | * @param array $options 652 | * @return Generator> 653 | */ 654 | protected function executeSnapshotQuery(string $query, array $options): Generator 655 | { 656 | $executeOptions = Arr::only($options, ['parameters', 'types', 'queryOptions', 'requestOptions']); 657 | assert($this->currentSnapshot !== null); 658 | return $this->currentSnapshot->execute($query, $executeOptions)->rows(); 659 | } 660 | 661 | /** 662 | * @param Transaction $transaction 663 | * @param string $query 664 | * @param list $bindings 665 | * @return int 666 | */ 667 | protected function executeDml(Transaction $transaction, string $query, array $bindings = []): int 668 | { 669 | $rowCount = $transaction->executeUpdate($query, ['parameters' => $this->prepareBindings($bindings)]); 670 | $this->recordsHaveBeenModified($rowCount > 0); 671 | return $rowCount; 672 | } 673 | 674 | /** 675 | * @param Transaction $transaction 676 | * @param string $query 677 | * @param list $bindings 678 | * @return int 679 | */ 680 | protected function executeBatchDml(Transaction $transaction, string $query, array $bindings = []): int 681 | { 682 | $result = $transaction->executeUpdateBatch([ 683 | ['sql' => $query, 'parameters' => $this->prepareBindings($bindings)], 684 | ]); 685 | 686 | $error = $result->error(); 687 | if ($error !== null) { 688 | throw new ConflictException( 689 | $error['status']['message'] ?? '', 690 | $error['status']['code'] ?? 0, 691 | null, 692 | ['details' => $error['details'] ?? []], 693 | ); 694 | } 695 | 696 | $rowCount = array_sum($result->rowCounts()); 697 | $this->recordsHaveBeenModified($rowCount > 0); 698 | return $rowCount; 699 | } 700 | 701 | /** 702 | * @param array $options 703 | * @return bool 704 | */ 705 | protected function canExecuteAsReadWriteTransaction(array $options): bool 706 | { 707 | $readOnlyTriggers = [ 708 | 'singleUse', 709 | 'exactStaleness', 710 | 'maxStaleness', 711 | 'minReadTimestamp', 712 | 'readTimestamp', 713 | 'strong', 714 | ]; 715 | 716 | foreach ($readOnlyTriggers as $option) { 717 | if ($options[$option] ?? false) { 718 | return false; 719 | } 720 | } 721 | return true; 722 | } 723 | 724 | /** 725 | * @param string $query 726 | * @return bool 727 | */ 728 | protected function shouldRunAsBatchDml(string $query): bool 729 | { 730 | return stripos($query, 'insert or ') === 0; 731 | } 732 | 733 | /** 734 | * @template T 735 | * @param Closure(): T $callback 736 | * @return T 737 | */ 738 | protected function handleSessionNotFoundException(Closure $callback): mixed 739 | { 740 | $this->disconnect(); 741 | // Currently, there is no way for us to delete the session, so we have to delete the whole pool. 742 | // This might affect parallel processes. 743 | $this->clearSessionPool(); 744 | $this->reconnect(); 745 | return $callback(); 746 | } 747 | 748 | /** 749 | * Check if this is "session not found" error 750 | * 751 | * @param Throwable $e 752 | * @return bool 753 | */ 754 | protected function causedBySessionNotFound(Throwable $e): bool 755 | { 756 | if ($e instanceof QueryException) { 757 | $e = $e->getPrevious(); 758 | } 759 | 760 | return ($e instanceof NotFoundException) 761 | && str_contains($e->getMessage(), 'Session does not exist'); 762 | } 763 | } 764 | -------------------------------------------------------------------------------- /src/Console/CooldownCommand.php: -------------------------------------------------------------------------------- 1 | argument('connections'); 33 | if (count($connectionNames) === 0) { 34 | $connectionNames = array_keys((array)config('database.connections')); 35 | } 36 | 37 | $spannerConnectionNames = array_filter( 38 | $connectionNames, 39 | static fn(string $name): bool => config("database.connections.{$name}.driver") === 'spanner', 40 | ); 41 | 42 | foreach ($spannerConnectionNames as $name) { 43 | $connection = $db->connection($name); 44 | if ($connection instanceof SpannerConnection) { 45 | $connection->clearSessionPool(); 46 | $this->info("All sessions cleared for {$name}"); 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Console/SessionsCommand.php: -------------------------------------------------------------------------------- 1 | argument('connections'); 41 | if (count($connectionNames) === 0) { 42 | $connectionNames = array_keys((array)config('database.connections')); 43 | } 44 | 45 | $spannerConnectionNames = array_filter( 46 | $connectionNames, 47 | static fn(string $name): bool => config("database.connections.{$name}.driver") === 'spanner', 48 | ); 49 | 50 | foreach ($spannerConnectionNames as $name) { 51 | $connection = $db->connection($name); 52 | if (!($connection instanceof SpannerConnection)) { 53 | continue; 54 | } 55 | $sessions = $this->makeSessionData($connection); 56 | $message = "{$connection->getName()} contains {$sessions->count()} session(s)."; 57 | if ($label = $this->option('label')) { 58 | assert(is_string($label)); 59 | $message .= " (filtered by Label: {$label})"; 60 | } 61 | $this->info($message); 62 | if ($sessions->isNotEmpty()) { 63 | $headers = array_keys($sessions[0]); 64 | $this->table($headers, $sessions); 65 | } 66 | } 67 | } 68 | 69 | /** 70 | * @param Connection $connection 71 | * @return Collection 72 | */ 73 | protected function makeSessionData(Connection $connection): Collection 74 | { 75 | $descending = $this->getOrder() === 'desc'; 76 | $label = $this->option('label'); 77 | assert(is_string($label) || is_null($label)); 78 | 79 | $sessions = $connection->listSessions() 80 | ->sortBy(fn(SessionInfo $s): string => $this->getSortValue($s), descending: $descending); 81 | 82 | if ($label !== null) { 83 | $sessions = $sessions->filter(static function (SessionInfo $session) use ($label): bool { 84 | $labels = $session->getLabels(); 85 | [$key, $val] = explode('=', $label, 2); 86 | if (isset($labels[$key])) { 87 | return $labels[$key] === $val; 88 | } 89 | return false; 90 | }); 91 | } 92 | return $sessions->map(static fn(SessionInfo $s) => [ 93 | 'Name' => $s->getName(), 94 | 'Created' => (string)$s->getCreatedAt(), 95 | 'LastUsed' => (string)$s->getLastUsedAt(), 96 | 'Labels' => http_build_query($s->getLabels(), ', ', ''), 97 | ]); 98 | } 99 | 100 | /** 101 | * @param SessionInfo $session 102 | * @return string 103 | */ 104 | protected function getSortValue(SessionInfo $session): string 105 | { 106 | $sort = $this->option('sort'); 107 | assert(is_string($sort)); 108 | return match (Str::studly($sort)) { 109 | 'Name' => $session->getName(), 110 | 'Created' => (string) $session->getCreatedAt(), 111 | 'LastUsed' => (string) $session->getLastUsedAt(), 112 | 'Labels' => implode(', ', $session->getLabels()), 113 | default => throw new RuntimeException("Unknown column: {$sort}"), 114 | }; 115 | } 116 | 117 | /** 118 | * @return string 119 | */ 120 | protected function getOrder(): string 121 | { 122 | $order = $this->option('order'); 123 | assert(is_string($order)); 124 | 125 | $order = strtolower($order); 126 | 127 | if (!in_array($order, ['asc', 'desc'], true)) { 128 | throw new RuntimeException("Unknown order: {$order}. Must be [ASC, DESC]"); 129 | } 130 | 131 | return $order; 132 | } 133 | 134 | } 135 | -------------------------------------------------------------------------------- /src/Console/WarmupCommand.php: -------------------------------------------------------------------------------- 1 | argument('connections'); 39 | if (count($connectionNames) === 0) { 40 | $connectionNames = array_keys((array)config('database.connections')); 41 | } 42 | 43 | $spannerConnectionNames = array_filter( 44 | $connectionNames, 45 | static fn(string $name): bool => config("database.connections.{$name}.driver") === 'spanner', 46 | ); 47 | 48 | $refresh = (bool)($this->option('refresh') ?? false); 49 | $skipOnError = (bool)($this->option('skip-on-error') ?? false); 50 | 51 | foreach ($spannerConnectionNames as $name) { 52 | $connection = $db->connection($name); 53 | 54 | if (!$connection instanceof SpannerConnection) { 55 | continue; 56 | } 57 | 58 | try { 59 | if ($refresh) { 60 | $this->info("Cleared all existing sessions for {$name}"); 61 | $connection->clearSessionPool(); 62 | } 63 | 64 | $count = $connection->warmupSessionPool(); 65 | $this->info("Warmed up {$count} sessions for {$name}"); 66 | } catch (ServiceException $e) { 67 | $skipOnError 68 | ? $this->warn("Skipping warmup for {$name} due to " . $e::class . ": {$e->getMessage()}") 69 | : throw $e; 70 | } 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Eloquent/Concerns/InterleaveKeySupport.php: -------------------------------------------------------------------------------- 1 | 29 | */ 30 | public function getInterleaveKeys(): array 31 | { 32 | return $this->interleaveKeys; 33 | } 34 | 35 | /** 36 | * @inheritDoc 37 | */ 38 | protected function setKeysForSelectQuery($query) 39 | { 40 | $interleaveKeys = $this->getInterleaveKeys(); 41 | 42 | if (empty($interleaveKeys)) { 43 | return parent::setKeysForSaveQuery($query); 44 | } 45 | 46 | foreach ($interleaveKeys as $keyName) { 47 | $query->where($keyName, '=', $this->getAttribute($keyName)); 48 | } 49 | 50 | return $query; 51 | } 52 | 53 | /** 54 | * @inheritDoc 55 | */ 56 | protected function setKeysForSaveQuery($query) 57 | { 58 | $interleaveKeys = $this->getInterleaveKeys(); 59 | 60 | if (empty($interleaveKeys)) { 61 | return parent::setKeysForSaveQuery($query); 62 | } 63 | 64 | foreach ($interleaveKeys as $keyName) { 65 | $query->where($keyName, '=', $this->getAttribute($keyName)); 66 | } 67 | 68 | return $query; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Eloquent/Model.php: -------------------------------------------------------------------------------- 1 | 28 | */ 29 | class Model extends BaseModel 30 | { 31 | use Concerns\InterleaveKeySupport; 32 | 33 | /** 34 | * @var list 35 | */ 36 | protected $interleaveKeys = []; 37 | 38 | /** 39 | * @var bool 40 | */ 41 | public $incrementing = false; 42 | 43 | /** 44 | * @param BaseModel|Relation $query 45 | * @param scalar $value 46 | * @param string|null $field 47 | * @return BuilderContract 48 | */ 49 | public function resolveRouteBindingQuery($query, $value, $field = null) 50 | { 51 | // value needs to be casted to prevent "No matching signature" error. 52 | // Ex: if table is INT64 and value is string it would throw this error. 53 | $key = $field ?? $this->getRouteKeyName(); 54 | return parent::resolveRouteBindingQuery($query, $this->tryCastAttribute($key, $value), $field); 55 | } 56 | 57 | /** 58 | * @param string $childType 59 | * @param scalar $value 60 | * @param string|null $field 61 | * @return Relation 62 | */ 63 | protected function resolveChildRouteBindingQuery($childType, $value, $field) 64 | { 65 | $relationship = $this->{Str::plural(Str::camel($childType))}(); 66 | $key = $field ?: $relationship->getRelated()->getRouteKeyName(); 67 | return parent::resolveChildRouteBindingQuery($childType, $this->tryCastAttribute($key, $value), $field); 68 | } 69 | 70 | /** 71 | * @param string $key 72 | * @param scalar $value 73 | * @return mixed 74 | */ 75 | protected function tryCastAttribute(string $key, $value) 76 | { 77 | if (array_key_exists($key, $this->getCasts())) { 78 | return $this->castAttribute($key, $value); 79 | } 80 | 81 | if ($key === $this->getKeyName() && $this->getKeyType() === 'int') { 82 | return (int) $value; 83 | } 84 | 85 | return $value; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Events/MutatingData.php: -------------------------------------------------------------------------------- 1 | 37 | */ 38 | public $values; 39 | 40 | /** 41 | * @param Connection $connection 42 | * @param string $tableName 43 | * @param string $command 44 | * @param array $values 45 | */ 46 | public function __construct($connection, string $tableName, string $command, array $values) 47 | { 48 | parent::__construct($connection); 49 | 50 | $this->tableName = $tableName; 51 | $this->command = $command; 52 | $this->values = $values; 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /src/Query/ArrayValue.php: -------------------------------------------------------------------------------- 1 | $value 27 | */ 28 | public function __construct( 29 | public array $value, 30 | ) 31 | { 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Query/Builder.php: -------------------------------------------------------------------------------- 1 | $values 49 | */ 50 | public function insert(array $values) 51 | { 52 | return parent::insert($this->prepareInsertForDml($values)); 53 | } 54 | 55 | /** 56 | * {@inheritDoc} 57 | * @param array $values 58 | */ 59 | public function update(array $values) 60 | { 61 | foreach ($values as $key => $value) { 62 | if (is_array($value)) { 63 | assert(array_is_list($value)); 64 | $values[$key] = new ArrayValue($value); 65 | } 66 | } 67 | 68 | return parent::update($values); 69 | } 70 | 71 | /** 72 | * {@inheritDoc} 73 | * @param array $attributes 74 | * @param array|callable(bool): mixed $values 75 | */ 76 | public function updateOrInsert(array $attributes, array|callable $values = []) 77 | { 78 | $exists = $this->where($attributes)->exists(); 79 | 80 | if ($values instanceof Closure) { 81 | $values = $values($exists); 82 | } 83 | 84 | if (!$exists) { 85 | return $this->insert(array_merge($attributes, $values)); 86 | } 87 | 88 | if (empty($values)) { 89 | return true; 90 | } 91 | 92 | return (bool)$this->limit(1)->update(Arr::except($values, array_keys($attributes))); 93 | } 94 | 95 | /** 96 | * {@inheritDoc} 97 | * @param array $values 98 | * @param list $uniqueBy 99 | * @param array|null $update 100 | */ 101 | public function upsert(array $values, $uniqueBy = [], $update = null) 102 | { 103 | if (empty($values)) { 104 | return 0; 105 | } 106 | 107 | if (!array_is_list($values)) { 108 | $values = [$values]; 109 | } else { 110 | foreach ($values as $key => $value) { 111 | ksort($value); 112 | 113 | $values[$key] = $value; 114 | } 115 | } 116 | 117 | $this->applyBeforeQueryCallbacks(); 118 | 119 | return $this->connection->affectingStatement( 120 | $this->grammar->compileUpsert($this, $values, [], []), 121 | $this->cleanBindings(Arr::flatten($values, 1)), 122 | ); 123 | } 124 | 125 | /** 126 | * @inheritDoc 127 | */ 128 | public function truncate() 129 | { 130 | $this->applyBeforeQueryCallbacks(); 131 | 132 | foreach ($this->grammar->compileTruncate($this) as $sql => $bindings) { 133 | $this->connection->runPartitionedDml($sql, $bindings); 134 | } 135 | } 136 | 137 | /** 138 | * {@inheritDoc} 139 | * @param array|Arrayable $values 140 | */ 141 | public function whereIn($column, $values, $boolean = 'and', $not = false) 142 | { 143 | // If parameter is over the limit, Spanner will throw an error. We will bypass this limit by 144 | // using UNNEST(). This is enabled by default, but can be disabled by setting the config. 145 | $unnestThreshold = $this->connection->getConfig('parameter_unnest_threshold') ?? self::DEFAULT_UNNEST_THRESHOLD; 146 | if ($unnestThreshold !== false && is_countable($values) && count($values) > $unnestThreshold) { 147 | return $this->whereInUnnest($column, $values, $boolean, $not); 148 | } 149 | 150 | return parent::whereIn($column, $values, $boolean, $not); 151 | } 152 | 153 | /** 154 | * NOTE: We will attempt to bind column names included in UNNEST() here. 155 | * @see https://cloud.google.com/spanner/docs/lexical#query-parameters 156 | * > Query parameters can be used in substitution of arbitrary expressions. 157 | * > They cannot, however, be used in substitution of identifiers, 158 | * > column names, table names, or other parts of the query itself. 159 | * The regular expression was taken from the documentation below 160 | * @see https://cloud.google.com/spanner/docs/lexical 161 | * 162 | * @param string $column 163 | * @param mixed $value 164 | * @param string $boolean 165 | * @return $this 166 | */ 167 | public function whereInArray(string $column, $value, string $boolean = 'and') 168 | { 169 | $type = 'InArray'; 170 | 171 | $this->wheres[] = compact('type', 'column', 'value', 'boolean'); 172 | 173 | $this->addBinding($value); 174 | 175 | return $this; 176 | } 177 | 178 | /** 179 | * @param string|Expression $column 180 | * @param array|Arrayable $values 181 | * @param string $boolean 182 | * @param bool $not 183 | * @return $this 184 | */ 185 | public function whereInUnnest(string|Expression $column, $values, string $boolean = 'and', bool $not = false) 186 | { 187 | $type = 'InUnnest'; 188 | 189 | // prevent getBindings() from flattening the array by wrapping it in a class 190 | $values = ($values instanceof Nested) ? $values : new Nested($values); 191 | 192 | $this->wheres[] = compact('type', 'column', 'values', 'boolean', 'not'); 193 | 194 | $this->addBinding($values); 195 | 196 | return $this; 197 | } 198 | 199 | /** 200 | * @param string $column 201 | * @param array|Arrayable|Nested $values 202 | * @param string $boolean 203 | * @return $this 204 | */ 205 | public function whereNotInUnnest(string $column, array|Arrayable|Nested $values, string $boolean = 'and'): static 206 | { 207 | return $this->whereInUnnest($column, $values, $boolean, true); 208 | } 209 | 210 | /** 211 | * @param array $values 212 | * @return array 213 | */ 214 | protected function prepareInsertForDml($values) 215 | { 216 | if (empty($values)) { 217 | return []; 218 | } 219 | 220 | if (Arr::isAssoc($values)) { 221 | return [$values]; 222 | } 223 | 224 | return $values; 225 | } 226 | 227 | /** 228 | * {@inheritDoc} 229 | * @return array> 230 | */ 231 | protected function runSelect() 232 | { 233 | $sql = $this->toSql(); 234 | $bindings = $this->getBindings(); 235 | $options = []; 236 | 237 | $requestTimeoutSeconds = $this->getRequestTimeoutSeconds(); 238 | if ($requestTimeoutSeconds !== null) { 239 | $options['requestTimeout'] = $requestTimeoutSeconds; 240 | } 241 | 242 | if ($this->dataBoostEnabled()) { 243 | $options['dataBoostEnabled'] = true; 244 | } 245 | 246 | if ($this->snapshotEnabled()) { 247 | $options['snapshotEnabled'] = $this->snapshotEnabled(); 248 | $options['snapshotTimestampBound'] = $this->snapshotTimestampBound(); 249 | } 250 | 251 | if ($this->timestampBound !== null) { 252 | $options += $this->timestampBound->transactionOptions(); 253 | } 254 | 255 | return $this->connection->selectWithOptions($sql, $bindings, $options); 256 | } 257 | 258 | /** 259 | * @param string $index 260 | * @return $this 261 | */ 262 | public function forceIndex($index): static 263 | { 264 | $this->indexHint = new IndexHint('force', $index); 265 | 266 | return $this; 267 | } 268 | 269 | /** 270 | * @return $this 271 | */ 272 | public function disableEmulatorNullFilteredIndexCheck(): static 273 | { 274 | $indexHint = $this->indexHint; 275 | 276 | if ($indexHint === null) { 277 | throw new LogicException('Force index must be set before disabling null filter index check'); 278 | } 279 | 280 | assert($indexHint instanceof IndexHint); 281 | $indexHint->disableEmulatorNullFilteredIndexCheck = true; 282 | 283 | return $this; 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /src/Query/Concerns/SetsRequestTimeouts.php: -------------------------------------------------------------------------------- 1 | requestTimeoutSeconds = $seconds; 34 | return $this; 35 | } 36 | 37 | /** 38 | * @return float|null 39 | */ 40 | public function getRequestTimeoutSeconds(): ?float 41 | { 42 | return $this->requestTimeoutSeconds; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Query/Concerns/UsesDataBoost.php: -------------------------------------------------------------------------------- 1 | useDataBoost = $toggle; 34 | return $this; 35 | } 36 | 37 | /** 38 | * @return bool 39 | */ 40 | public function dataBoostEnabled(): bool 41 | { 42 | return $this->useDataBoost; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Query/Concerns/UsesFullTextSearch.php: -------------------------------------------------------------------------------- 1 | $options 26 | * @param string $boolean 27 | * @return $this 28 | */ 29 | public function searchFullText( 30 | string $tokens, 31 | string $query, 32 | array $options = [], 33 | string $boolean = 'and', 34 | ): static 35 | { 36 | $this->addSearchCondition('SearchFullText', $tokens, $query, $options, $boolean); 37 | return $this; 38 | } 39 | 40 | /** 41 | * @param string $tokens 42 | * @param string $query 43 | * @param array $options 44 | * @param string $boolean 45 | * @return $this 46 | */ 47 | public function searchNgrams( 48 | string $tokens, 49 | string $query, 50 | array $options = [], 51 | string $boolean = 'and', 52 | ): static 53 | { 54 | $this->addSearchCondition('SearchNgrams', $tokens, $query, $options, $boolean); 55 | return $this; 56 | } 57 | 58 | /** 59 | * @param string $tokens 60 | * @param string $query 61 | * @param array $options 62 | * @param string $boolean 63 | * @return $this 64 | */ 65 | public function searchSubstring( 66 | string $tokens, 67 | string $query, 68 | array $options = [], 69 | string $boolean = 'and', 70 | ): static 71 | { 72 | $this->addSearchCondition('SearchSubstring', $tokens, $query, $options, $boolean); 73 | return $this; 74 | } 75 | 76 | /** 77 | * @param string $type 78 | * @param string $tokens 79 | * @param string $query 80 | * @param array $options 81 | * @param string $boolean 82 | * @return void 83 | */ 84 | protected function addSearchCondition( 85 | string $type, 86 | string $tokens, 87 | string $query, 88 | array $options = [], 89 | string $boolean = 'and', 90 | ): void 91 | { 92 | $this->wheres[] = [ 93 | 'type' => $type, 94 | 'tokens' => $tokens, 95 | 'boolean' => $boolean, 96 | 'query' => $query, 97 | 'options' => $options, 98 | ]; 99 | $this->addBinding($query); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Query/Concerns/UsesMutations.php: -------------------------------------------------------------------------------- 1 | connection->insertUsingMutation($this->getTableName(), $values); 38 | } 39 | 40 | /** 41 | * @param TDataSet $values 42 | * @return void 43 | */ 44 | public function updateUsingMutation(array $values) 45 | { 46 | $this->connection->updateUsingMutation($this->getTableName(), $values); 47 | } 48 | 49 | /** 50 | * @param TDataSet $values 51 | * @return void 52 | */ 53 | public function insertOrUpdateUsingMutation(array $values) 54 | { 55 | $this->connection->insertOrUpdateUsingMutation($this->getTableName(), $values); 56 | } 57 | 58 | /** 59 | * @param list|KeySet $keys 60 | * @return void 61 | */ 62 | public function deleteUsingMutation($keys) 63 | { 64 | $this->connection->deleteUsingMutation($this->getTableName(), $keys); 65 | } 66 | 67 | /** 68 | * @return string 69 | */ 70 | private function getTableName(): string 71 | { 72 | return (string) $this->getGrammar()->getValue($this->from); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Query/Concerns/UsesPartitionedDml.php: -------------------------------------------------------------------------------- 1 | $values 31 | * @return int affected rows count 32 | */ 33 | public function partitionedUpdate(array $values) 34 | { 35 | $sql = $this->grammar->compileUpdate($this, $values); 36 | return $this->connection->runPartitionedDml($sql, $this->cleanBindings( 37 | $this->grammar->prepareBindingsForUpdate($this->bindings, $values), 38 | )); 39 | } 40 | 41 | /** 42 | * @return int 43 | */ 44 | public function partitionedDelete() 45 | { 46 | return $this->connection->runPartitionedDml( 47 | $this->grammar->compileDelete($this), 48 | $this->cleanBindings($this->grammar->prepareBindingsForDelete($this->bindings)), 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Query/Concerns/UsesSnapshots.php: -------------------------------------------------------------------------------- 1 | snapshotTimestampBound = $timestamp; 36 | return $this; 37 | } 38 | 39 | /** 40 | * @return bool 41 | */ 42 | public function snapshotEnabled(): bool 43 | { 44 | return isset($this->snapshotTimestampBound); 45 | } 46 | 47 | /** 48 | * @return TimestampBoundInterface|null 49 | */ 50 | public function snapshotTimestampBound(): TimestampBoundInterface|null 51 | { 52 | return $this->snapshotTimestampBound; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Query/Concerns/UsesStaleReads.php: -------------------------------------------------------------------------------- 1 | timestampBound = $timestampBound; 36 | return $this; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Query/Grammar.php: -------------------------------------------------------------------------------- 1 | $values 36 | */ 37 | public function compileInsertOrIgnore(Builder $query, array $values) 38 | { 39 | return Str::replaceFirst('insert', 'insert or ignore', $this->compileInsert($query, $values)); 40 | } 41 | 42 | /** 43 | * {@inheritDoc} 44 | * @param array $values 45 | * @param list $uniqueBy 46 | * @param array $update 47 | */ 48 | public function compileUpsert(Builder $query, array $values, array $uniqueBy, array $update) 49 | { 50 | return Str::replaceFirst('insert', 'insert or update', $this->compileInsert($query, $values)); 51 | } 52 | 53 | /** 54 | * {@inheritDoc} 55 | * @param array $values 56 | * @param string|null $sequence 57 | */ 58 | public function compileInsertGetId(Builder $query, $values, $sequence) 59 | { 60 | return $this->compileInsert($query, $values) . ' then return ' . $this->wrap($sequence ?? 'id'); 61 | } 62 | 63 | /** 64 | * @inheritDoc 65 | */ 66 | protected function compileLock(Builder $query, $value) 67 | { 68 | return $value === true ? 'for update' : ''; 69 | } 70 | 71 | /** 72 | * {@inheritDoc} 73 | * @param array $bindings 74 | * @param array $values 75 | * @return array 76 | */ 77 | public function prepareBindingsForUpdate(array $bindings, array $values) 78 | { 79 | $bindings = parent::prepareBindingsForUpdate($bindings, $values); 80 | foreach ($bindings as $key => $value) { 81 | if ($value instanceof ArrayValue) { 82 | $bindings[$key] = $value->value; 83 | } 84 | } 85 | return $bindings; 86 | } 87 | 88 | /** 89 | * {@inheritDoc} 90 | * @return non-empty-array 91 | */ 92 | public function compileTruncate(Builder $query) 93 | { 94 | return ['delete from ' . $this->wrapTable($query->from) . ' where true' => []]; 95 | } 96 | 97 | /** 98 | * @param Builder $query 99 | * @param IndexHint $indexHint 100 | * @return string 101 | */ 102 | protected function compileIndexHint(Builder $query, $indexHint) 103 | { 104 | if ($indexHint->index === null) { 105 | return ''; 106 | } 107 | 108 | $statements = []; 109 | 110 | $statements[] = match ($indexHint->type) { 111 | 'force' => "FORCE_INDEX={$indexHint->index}", 112 | default => $this->markAsNotSupported('index type: ' . $indexHint->type), 113 | }; 114 | 115 | if ($indexHint->disableEmulatorNullFilteredIndexCheck) { 116 | $statements[] = 'spanner_emulator.disable_query_null_filtered_index_check=true'; 117 | } 118 | 119 | return '@{' . implode(',', $statements) . '}'; 120 | } 121 | 122 | /** 123 | * @param Builder $query 124 | * @param array $where 125 | * @return string 126 | */ 127 | protected function whereInArray(Builder $query, $where) 128 | { 129 | return '? in unnest(' . $this->wrap($where['column']) . ')'; 130 | } 131 | 132 | /** 133 | * @param Builder $query 134 | * @param array{ values: Nested, column: string, not: bool } $where 135 | * @return string 136 | */ 137 | protected function whereInUnnest(Builder $query, $where) 138 | { 139 | $values = $where['values']; 140 | 141 | /** 142 | * Note: Additional inspection due to inability to constrain language level. 143 | * @phpstan-ignore instanceof.alwaysTrue 144 | */ 145 | if (!($values instanceof Nested)) { 146 | throw new RuntimeException('Invalid Type:' . get_class($values) . ' given. ' . Nested::class . ' expected.'); 147 | } 148 | 149 | if (count($values) <= 0) { 150 | return '0 = 1'; 151 | } 152 | 153 | return $this->wrap($where['column']) 154 | . ($where['not'] ? ' not' : '') 155 | . ' in unnest(?)'; 156 | } 157 | 158 | /** 159 | * @param Builder $query 160 | * @param array{ tokens: string, query: string, options: array } $where 161 | * @return string 162 | */ 163 | protected function whereSearchFullText(Builder $query, array $where): string 164 | { 165 | return $this->buildSearchFunction('search', $where); 166 | } 167 | 168 | /** 169 | * @param Builder $query 170 | * @param array{ tokens: string, query: string, options: array } $where 171 | * @return string 172 | */ 173 | protected function whereSearchNgrams(Builder $query, array $where): string 174 | { 175 | return $this->buildSearchFunction('search_ngrams', $where); 176 | } 177 | 178 | /** 179 | * @param Builder $query 180 | * @param array{ tokens: string, query: string, options: array } $where 181 | * @return string 182 | */ 183 | protected function whereSearchSubstring(Builder $query, array $where): string 184 | { 185 | return $this->buildSearchFunction('search_substring', $where); 186 | } 187 | 188 | /** 189 | * @param string $function 190 | * @param array{ tokens: string, query: string, options: array } $where 191 | * @return string 192 | */ 193 | protected function buildSearchFunction(string $function, array $where): string 194 | { 195 | $tokens = $this->wrap($where['tokens']); 196 | $rawQuery = $where['query']; 197 | $options = $where['options']; 198 | return $function . '(' . implode(', ', array_filter([ 199 | $tokens, 200 | $this->quoteString($rawQuery), 201 | $this->formatOptions($options, ' => '), 202 | ])) . ')'; 203 | } 204 | 205 | /** 206 | * @inheritDoc 207 | */ 208 | public function supportsSavepoints() 209 | { 210 | return false; 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/Query/IndexHint.php: -------------------------------------------------------------------------------- 1 | 28 | * @implements IteratorAggregate 29 | */ 30 | class Nested implements Arrayable, IteratorAggregate, Countable 31 | { 32 | /** 33 | * @var array 34 | */ 35 | private array $array; 36 | 37 | /** 38 | * @param array|Arrayable $array 39 | */ 40 | public function __construct(array|Arrayable $array) 41 | { 42 | $this->array = ($array instanceof Arrayable) 43 | ? $array->toArray() 44 | : $array; 45 | } 46 | 47 | /** 48 | * @return array 49 | */ 50 | public function toArray(): array 51 | { 52 | return array_values($this->array); 53 | } 54 | 55 | /** 56 | * @return ArrayIterator 57 | */ 58 | public function getIterator(): ArrayIterator 59 | { 60 | return new ArrayIterator($this->array); 61 | } 62 | 63 | /** 64 | * @return int 65 | */ 66 | public function count(): int 67 | { 68 | return count($this->array); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Query/Parameterizer.php: -------------------------------------------------------------------------------- 1 | $bindings 39 | * @return array{ 0: string, 1: array } 40 | */ 41 | public function parameterizeQuery(string $query, array $bindings): array 42 | { 43 | $newBindings = []; 44 | $i = 0; 45 | $newQuery = preg_replace_callback('/\?/', function () use ($query, $bindings, &$newBindings, &$i) { 46 | $binding = $bindings[$i]; 47 | $result = null; 48 | if ($binding === null) { 49 | $result = 'NULL'; 50 | } elseif (is_array($binding) && empty($binding)) { 51 | $result = '[]'; 52 | } elseif (is_string($binding) && self::hasLikeWildcard($query, $binding)) { 53 | $result = self::createLikeClause($binding); 54 | } else { 55 | $placeHolder = 'p' . $i; 56 | $newBindings[$placeHolder] = $binding; 57 | $result = "@$placeHolder"; 58 | } 59 | $i += 1; 60 | return $result; 61 | }, $query); 62 | 63 | assert(is_string($newQuery)); 64 | 65 | return [$newQuery, $newBindings]; 66 | } 67 | 68 | /** 69 | * @param string $query 70 | * @param string $value 71 | * @return bool 72 | */ 73 | private static function hasLikeWildcard(string $query, string $value) 74 | { 75 | return Str::contains(strtolower($query), 'like') 76 | && Str::contains($value, ['%', '_']) 77 | && (Str::startsWith($value, ['%', '_']) || preg_match('/[^\\\\][%_]/', $value)); 78 | } 79 | 80 | /** 81 | * @param string $value 82 | * @return string 83 | */ 84 | private static function createLikeClause(string $value) 85 | { 86 | if (Str::contains($value, "\n")) { 87 | return "'''" . addslashes($value) . "'''"; 88 | } 89 | return "'" . addslashes($value) . "'"; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Query/Processor.php: -------------------------------------------------------------------------------- 1 | $values 33 | */ 34 | public function processInsertGetId(Builder $query, $sql, $values, $sequence = null) 35 | { 36 | $connection = $query->getConnection(); 37 | 38 | $connection->recordsHaveBeenModified(); 39 | 40 | $queryCall = static fn() => $connection->selectOne($sql, $values); 41 | 42 | $result = $connection->transactionLevel() > 0 43 | ? $queryCall() 44 | : $connection->transaction($queryCall); 45 | 46 | $sequence ??= 'id'; 47 | 48 | $id = match(true) { 49 | is_object($result) => $result->{$sequence}, 50 | is_array($result) => $result[$sequence], 51 | default => throw new LogicException('Unknown result type : ' . gettype($result)), 52 | }; 53 | 54 | assert(is_int($id)); 55 | 56 | return $id; 57 | } 58 | 59 | /** 60 | * @inheritDoc 61 | * @param array> $results 62 | * @return array> 63 | */ 64 | public function processSelect(Builder $query, $results): array 65 | { 66 | foreach ($results as $index => $result) { 67 | foreach ($result as $name => $value) { 68 | if ($value instanceof ValueInterface) { 69 | $results[$index][$name] = $this->processColumn($value); 70 | } elseif (is_array($value)) { 71 | $array = []; 72 | foreach ($value as $k => $v) { 73 | $array[$k] = ($v instanceof ValueInterface) 74 | ? $this->processColumn($v) 75 | : $v; 76 | } 77 | $results[$index][$name] = $array; 78 | } 79 | } 80 | } 81 | return $results; 82 | } 83 | 84 | /** 85 | * @template TValue of mixed 86 | * @param TValue $value 87 | * @return ($value is Timestamp ? Carbon : ($value is Numeric ? string : TValue)) 88 | */ 89 | protected function processColumn(mixed $value): mixed 90 | { 91 | if ($value instanceof Timestamp) { 92 | return Carbon::instance($value->get())->setTimezone(date_default_timezone_get()); 93 | } 94 | 95 | if ($value instanceof Numeric) { 96 | return $value->formatAsString(); 97 | } 98 | 99 | return $value; 100 | } 101 | 102 | /** 103 | * @inheritDoc 104 | */ 105 | public function processTables($results) 106 | { 107 | return array_map(function ($result) { 108 | $result = (object) $result; 109 | 110 | return [ 111 | 'name' => $result->name, 112 | 'schema' => $result->schema !== '' ? $result->schema : null, 113 | 'schema_qualified_name' => $result->schema !== '' 114 | ? $result->schema . '.' . $result->name 115 | : $result->name, 116 | 'parent' => $result->parent, 117 | 'size' => null, 118 | 'comment' => null, 119 | 'collation' => null, 120 | 'engine' => null, 121 | ]; 122 | }, $results); 123 | } 124 | 125 | /** 126 | * @inheritDoc 127 | */ 128 | public function processColumns($results) 129 | { 130 | return array_map(static function (array $result) { 131 | $result = (object) $result; 132 | 133 | return [ 134 | 'name' => $result->name, 135 | 'type_name' => (string) preg_replace("/\([^)]+\)/", "", $result->type), 136 | 'type' => $result->type, 137 | 'collation' => null, 138 | 'nullable' => $result->nullable === 'YES', 139 | 'default' => $result->default, 140 | // TODO check IS_IDENTITY and set auto_increment accordingly 141 | 'auto_increment' => false, 142 | 'comment' => null, 143 | 'generation' => null, 144 | ]; 145 | }, $results); 146 | } 147 | 148 | /** 149 | * @inheritDoc 150 | */ 151 | public function processIndexes($results) 152 | { 153 | return array_map(function ($result) { 154 | $result = (object) $result; 155 | 156 | return [ 157 | 'name' => $name = $result->name, 158 | 'columns' => $result->columns ? explode(',', $result->columns) : [], 159 | 'type' => strtolower($result->type), 160 | 'unique' => (bool) $result->unique, 161 | 'primary' => $name === 'PRIMARY_KEY', 162 | ]; 163 | }, $results); 164 | } 165 | 166 | /** 167 | * @inheritDoc 168 | */ 169 | public function processForeignKeys($results) 170 | { 171 | return array_map(function ($result) { 172 | $result = (object) $result; 173 | 174 | return [ 175 | 'name' => $result->name, 176 | 'columns' => explode(',', $result->columns), 177 | 'foreign_schema' => $result->foreign_schema, 178 | 'foreign_table' => $result->foreign_table, 179 | 'foreign_columns' => explode(',', $result->foreign_columns), 180 | 'on_update' => strtolower($result->on_update), 181 | 'on_delete' => strtolower($result->on_delete), 182 | ]; 183 | }, $results); 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/Schema/Blueprint.php: -------------------------------------------------------------------------------- 1 | __FUNCTION__, 'name' => $column, 'autoIncrement' => $autoIncrement]); 40 | $this->addColumnDefinition($definition); 41 | return $definition; 42 | } 43 | 44 | /** 45 | * @inheritDoc 46 | * @return IntColumnDefinition 47 | */ 48 | public function integer($column, $autoIncrement = false, $unsigned = false): IntColumnDefinition 49 | { 50 | return $this->bigInteger($column, $autoIncrement, $unsigned); 51 | } 52 | 53 | /** 54 | * @inheritDoc 55 | * @return never 56 | */ 57 | public function temporary() 58 | { 59 | $this->markAsNotSupported('temporary table'); 60 | } 61 | 62 | /** 63 | * {@inheritDoc} 64 | * @param string|array|null $index 65 | * @return never 66 | */ 67 | public function dropPrimary($index = null) 68 | { 69 | $this->markAsNotSupported('dropping primary key'); 70 | } 71 | 72 | /** 73 | * @inheritDoc 74 | */ 75 | public function increments($column) 76 | { 77 | return $this->uuid($column) 78 | ->generateUuid() 79 | ->primary(); 80 | } 81 | 82 | /** 83 | * @inheritDoc 84 | */ 85 | public function bigIncrements($column) 86 | { 87 | return $this->increments($column); 88 | } 89 | 90 | /** 91 | * @inheritDoc 92 | */ 93 | public function mediumIncrements($column) 94 | { 95 | return $this->increments($column); 96 | } 97 | 98 | /** 99 | * @inheritDoc 100 | */ 101 | public function smallIncrements($column) 102 | { 103 | return $this->increments($column); 104 | } 105 | 106 | /** 107 | * @inheritDoc 108 | */ 109 | public function tinyIncrements($column) 110 | { 111 | return $this->increments($column); 112 | } 113 | 114 | /** 115 | * @inheritDoc 116 | * @param int|'max'|null $length add support for 'max' 117 | */ 118 | public function string($column, $length = null) 119 | { 120 | /** @phpstan-ignore argument.type */ 121 | return parent::string($column, $length); 122 | } 123 | 124 | /** 125 | * @inheritDoc 126 | * @return UuidColumnDefinition 127 | */ 128 | public function uuid($column = 'uuid') 129 | { 130 | $definition = new UuidColumnDefinition(['type' => 'uuid', 'name' => $column]); 131 | $this->addColumnDefinition($definition); 132 | return $definition; 133 | } 134 | 135 | /** 136 | * @param string $column 137 | * @param int|null $length 138 | * @param bool $fixed 139 | * @return ColumnDefinition 140 | */ 141 | public function binary($column, $length = null, $fixed = false) 142 | { 143 | $length = $length ?: Builder::$defaultBinaryLength; 144 | 145 | return $this->addColumn('binary', $column, compact('length')); 146 | } 147 | 148 | /** 149 | * @param string $column 150 | * @param int $total 151 | * @param int $places 152 | * @return ColumnDefinition 153 | */ 154 | public function decimal($column, $total = 38, $places = 9) 155 | { 156 | if ($total !== 38) { 157 | $this->markAsNotSupported('decimal with precision other than 38'); 158 | } 159 | 160 | if ($places !== 9) { 161 | $this->markAsNotSupported('decimal with scale other than 9'); 162 | } 163 | 164 | return parent::decimal($column, $total, $places); 165 | } 166 | 167 | /** 168 | * @param string $column 169 | * @return ColumnDefinition 170 | */ 171 | public function booleanArray($column) 172 | { 173 | return $this->addColumn('array', $column, [ 174 | 'arrayType' => 'boolean', 175 | ]); 176 | } 177 | 178 | /** 179 | * @param string $column 180 | * @return ColumnDefinition 181 | */ 182 | public function integerArray($column) 183 | { 184 | return $this->addColumn('array', $column, [ 185 | 'arrayType' => 'integer', 186 | ]); 187 | } 188 | 189 | /** 190 | * @param string $column 191 | * @return ColumnDefinition 192 | */ 193 | public function floatArray($column) 194 | { 195 | return $this->addColumn('array', $column, [ 196 | 'arrayType' => 'float', 197 | ]); 198 | } 199 | 200 | /** 201 | * @param string $column 202 | * @return ColumnDefinition 203 | */ 204 | public function decimalArray($column) 205 | { 206 | return $this->addColumn('array', $column, [ 207 | 'arrayType' => 'decimal', 208 | ]); 209 | } 210 | 211 | /** 212 | * @param string $column 213 | * @param int|string|null $length 214 | * @return ColumnDefinition 215 | */ 216 | public function stringArray($column, $length = null) 217 | { 218 | $length ??= Builder::$defaultStringLength; 219 | 220 | return $this->addColumn('array', $column, [ 221 | 'arrayType' => 'string', 222 | 'length' => $length, 223 | ]); 224 | } 225 | 226 | /** 227 | * @param string $column 228 | * @return ColumnDefinition 229 | */ 230 | public function dateArray($column) 231 | { 232 | return $this->addColumn('array', $column, [ 233 | 'arrayType' => 'date', 234 | ]); 235 | } 236 | 237 | /** 238 | * @param string $column 239 | * @return ColumnDefinition 240 | */ 241 | public function timestampArray($column) 242 | { 243 | return $this->addColumn('array', $column, [ 244 | 'arrayType' => 'timestamp', 245 | ]); 246 | } 247 | 248 | /** 249 | * @param string $column 250 | * @param TokenizerFunction $function 251 | * @param string $target 252 | * @param array $options 253 | * @return ColumnDefinition 254 | */ 255 | public function tokenList(string $column, TokenizerFunction $function, string $target, array $options = []): ColumnDefinition 256 | { 257 | return $this->addColumn('tokenList', $column, [ 258 | 'function' => $function, 259 | 'target' => $target, 260 | 'options' => $options, 261 | ])->invisible()->nullable(); 262 | } 263 | 264 | /** 265 | * @param string $table 266 | * @return InterleaveDefinition 267 | */ 268 | public function interleaveInParent(string $table): InterleaveDefinition 269 | { 270 | return $this->interleaveIn($table, true); 271 | } 272 | 273 | /** 274 | * @param string $table 275 | * @param bool $parent 276 | * @return InterleaveDefinition 277 | */ 278 | public function interleaveIn(string $table, bool $parent = false): InterleaveDefinition 279 | { 280 | return $this->commands[] = new InterleaveDefinition( 281 | $this->createCommand('interleaveIn', [ 282 | 'table' => $table, 283 | 'inParent' => $parent, 284 | ])->getAttributes(), 285 | ); 286 | } 287 | 288 | /** 289 | * @param string|list $columns 290 | * @param string|null $name 291 | * @param string|null $algorithm 292 | * @return SearchIndexDefinition 293 | */ 294 | public function fullText($columns, $name = null, $algorithm = null) 295 | { 296 | $type = 'fullText'; 297 | $columns = (array) $columns; 298 | 299 | $this->commands[] = $command = new SearchIndexDefinition([ 300 | 'name' => $type, 301 | 'index' => $name ?? $this->createIndexName($type, $columns), 302 | 'columns' => $columns, 303 | 'algorithm' => $algorithm, 304 | ]); 305 | 306 | return $command; 307 | } 308 | 309 | /** 310 | * {@inheritdoc} 311 | * @return RenameDefinition 312 | */ 313 | public function rename($to): RenameDefinition 314 | { 315 | $this->commands[] = $command = new RenameDefinition(__FUNCTION__, $to); 316 | return $command; 317 | } 318 | 319 | /** 320 | * @param string $name 321 | * @return RenameDefinition 322 | */ 323 | public function addSynonym(string $name): RenameDefinition 324 | { 325 | $this->commands[] = $command = (new RenameDefinition(__FUNCTION__, ''))->synonym($name); 326 | return $command; 327 | } 328 | 329 | /** 330 | * @param string $name 331 | * @return RenameDefinition 332 | */ 333 | public function dropSynonym(string $name): RenameDefinition 334 | { 335 | $this->commands[] = $command = (new RenameDefinition(__FUNCTION__, ''))->synonym($name); 336 | return $command; 337 | } 338 | 339 | /** 340 | * @see https://cloud.google.com/spanner/docs/ttl#defining_a_row_deletion_policy 341 | * @param string $column 342 | * @param int $days 343 | * @return Fluent 344 | */ 345 | public function deleteRowsOlderThan(string $column, int $days) 346 | { 347 | return $this->addCommand('rowDeletionPolicy', [ 348 | 'policy' => 'olderThan', 349 | 'column' => $column, 350 | 'days' => $days, 351 | ]); 352 | } 353 | 354 | /** 355 | * @param string $column 356 | * @param int $days 357 | * @return Fluent 358 | */ 359 | public function addRowDeletionPolicy(string $column, int $days): Fluent 360 | { 361 | return $this->addCommand('addRowDeletionPolicy', [ 362 | 'policy' => 'olderThan', 363 | 'column' => $column, 364 | 'days' => $days, 365 | ]); 366 | } 367 | 368 | /** 369 | * @param string $column 370 | * @param int $days 371 | * @return Fluent 372 | */ 373 | public function replaceRowDeletionPolicy(string $column, int $days): Fluent 374 | { 375 | return $this->addCommand('replaceRowDeletionPolicy', [ 376 | 'policy' => 'olderThan', 377 | 'column' => $column, 378 | 'days' => $days, 379 | ]); 380 | } 381 | 382 | /** 383 | * @return Fluent 384 | */ 385 | public function dropRowDeletionPolicy(): Fluent 386 | { 387 | return $this->addCommand('dropRowDeletionPolicy'); 388 | } 389 | 390 | /** 391 | * @param string $name 392 | * @return SequenceDefinition 393 | */ 394 | public function createSequence(string $name): SequenceDefinition 395 | { 396 | $this->commands[] = $command = new SequenceDefinition(__FUNCTION__, $name); 397 | return $command; 398 | } 399 | 400 | /** 401 | * @param string $name 402 | * @return SequenceDefinition 403 | */ 404 | public function createSequenceIfNotExists(string $name): SequenceDefinition 405 | { 406 | $this->commands[] = $command = new SequenceDefinition(__FUNCTION__, $name); 407 | return $command; 408 | } 409 | 410 | /** 411 | * @param string $name 412 | * @return SequenceDefinition 413 | */ 414 | public function alterSequence(string $name): SequenceDefinition 415 | { 416 | $this->commands[] = $command = new SequenceDefinition(__FUNCTION__, $name); 417 | return $command; 418 | } 419 | 420 | /** 421 | * @param string $name 422 | * @return Fluent 423 | */ 424 | public function dropSequence(string $name): Fluent 425 | { 426 | return $this->addCommand(__FUNCTION__, ['sequence' => $name]); 427 | } 428 | 429 | /** 430 | * @param string $name 431 | * @return Fluent 432 | */ 433 | public function dropSequenceIfExists(string $name): Fluent 434 | { 435 | return $this->addCommand(__FUNCTION__, ['sequence' => $name]); 436 | } 437 | 438 | /** 439 | * @param string $name 440 | * @return ChangeStreamDefinition 441 | */ 442 | public function createChangeStream(string $name): ChangeStreamDefinition 443 | { 444 | $this->commands[] = $command = new ChangeStreamDefinition(__FUNCTION__, $name); 445 | return $command; 446 | } 447 | 448 | /** 449 | * @param string $name 450 | * @return ChangeStreamDefinition 451 | */ 452 | public function alterChangeStream(string $name): ChangeStreamDefinition 453 | { 454 | $this->commands[] = $command = new ChangeStreamDefinition(__FUNCTION__, $name); 455 | return $command; 456 | } 457 | 458 | public function dropChangeStream(string $name): ChangeStreamDefinition 459 | { 460 | $this->commands[] = $command = new ChangeStreamDefinition(__FUNCTION__, $name); 461 | return $command; 462 | } 463 | 464 | /** 465 | * @param string $type 466 | * @param string|list $columns 467 | * @param string $index 468 | * @param string|null $algorithm 469 | * @return IndexDefinition 470 | */ 471 | protected function indexCommand($type, $columns, $index, $algorithm = null) 472 | { 473 | $columns = (array) $columns; 474 | 475 | // If no name was specified for this index, we will create one using a basic 476 | // convention of the table name, followed by the columns, followed by an 477 | // index type, such as primary or index, which makes the index unique. 478 | $index = $index ?: $this->createIndexName($type, $columns); 479 | 480 | $this->commands[] = $command = new IndexDefinition([ 481 | 'name' => $type, 482 | 'index' => $index, 483 | 'columns' => $columns, 484 | 'algorithm' => $algorithm, 485 | ]); 486 | 487 | return $command; 488 | } 489 | } 490 | -------------------------------------------------------------------------------- /src/Schema/Builder.php: -------------------------------------------------------------------------------- 1 | $options 47 | * @return void 48 | */ 49 | public function setDatabaseOptions(array $options): void 50 | { 51 | $connection = $this->connection; 52 | $name = Str::afterLast($connection->getDatabaseName(), '/'); 53 | $line = implode(', ', Arr::map($options, fn($v, $k) => "$k = " . match (true) { 54 | is_null($v) => 'null', 55 | is_bool($v) => $v ? 'true' : 'false', 56 | is_string($v) => $this->grammar->quoteString($v), 57 | default => $v, 58 | })); 59 | $connection->statement("ALTER DATABASE `{$name}` SET OPTIONS ({$line})"); 60 | } 61 | 62 | /** 63 | * @deprecated Use Blueprint::dropIndex() instead. Will be removed in v10.0. 64 | * 65 | * @param string $table 66 | * @param string $name 67 | * @return void 68 | */ 69 | public function dropIndex($table, $name) 70 | { 71 | $blueprint = $this->createBlueprint($table); 72 | $blueprint->dropIndex($name); 73 | $this->build($blueprint); 74 | } 75 | 76 | /** 77 | * @deprecated Use Blueprint::dropIndex() instead. Will be removed in v10.0. 78 | * 79 | * @param string $table 80 | * @param string $name 81 | * @return void 82 | */ 83 | public function dropIndexIfExist($table, $name) 84 | { 85 | if (in_array($name, $this->getIndexListing($table), true)) { 86 | $blueprint = $this->createBlueprint($table); 87 | $blueprint->dropIndex($name); 88 | $this->build($blueprint); 89 | } 90 | } 91 | 92 | /** 93 | * @inheritDoc 94 | */ 95 | protected function createBlueprint($table, ?Closure $callback = null) 96 | { 97 | return isset($this->resolver) 98 | ? ($this->resolver)($table, $callback) 99 | : new Blueprint($this->connection, $table, $callback); 100 | } 101 | 102 | /** 103 | * @inheritDoc 104 | */ 105 | public function dropAllTables() 106 | { 107 | $connection = $this->connection; 108 | /** @var list $tables 118 | */ 119 | $tables = $this->getTables(); 120 | 121 | if (count($tables) === 0) { 122 | return; 123 | } 124 | 125 | $sortedTables = []; 126 | 127 | // add parents counter 128 | foreach ($tables as $table) { 129 | $sortedTables[$table['name']] = ['parents' => 0, ...$table]; 130 | } 131 | 132 | // loop through all tables and count how many parents they have 133 | foreach ($sortedTables as $key => $table) { 134 | if (!$table['parent']) { 135 | continue; 136 | } 137 | 138 | $current = $table; 139 | while ($current['parent']) { 140 | $table['parents'] += 1; 141 | $current = $sortedTables[$current['parent']]; 142 | } 143 | $sortedTables[$key] = $table; 144 | } 145 | 146 | // sort tables desc based on parent count 147 | usort($sortedTables, static fn($a, $b) => $b['parents'] <=> $a['parents']); 148 | 149 | // drop foreign keys first (otherwise index queries will include them) 150 | $queries = []; 151 | foreach ($sortedTables as $tableData) { 152 | $tableName = $tableData['name']; 153 | $foreigns = $this->getForeignKeys($tableName); 154 | $blueprint = $this->createBlueprint($tableName); 155 | foreach ($foreigns as $foreign) { 156 | $blueprint->dropForeign($foreign['name']); 157 | } 158 | array_push($queries, ...$blueprint->toSql()); 159 | } 160 | /** @var Connection $connection */ 161 | $connection->runDdlBatch($queries); 162 | 163 | // drop indexes and tables 164 | $queries = []; 165 | foreach ($sortedTables as $tableData) { 166 | $tableName = $tableData['name']; 167 | $indexes = $this->getIndexListing($tableName); 168 | $blueprint = $this->createBlueprint($tableName); 169 | foreach ($indexes as $index) { 170 | if ($index === 'PRIMARY_KEY') { 171 | continue; 172 | } 173 | $blueprint->dropIndex($index); 174 | } 175 | $blueprint->drop(); 176 | array_push($queries, ...$blueprint->toSql()); 177 | } 178 | $connection->runDdlBatch($queries); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/Schema/ChangeStreamDefinition.php: -------------------------------------------------------------------------------- 1 | 39 | */ 40 | class ChangeStreamDefinition extends Fluent 41 | { 42 | /** 43 | * @param string $name 44 | * @param string $stream 45 | * @param array> $tables 46 | */ 47 | public function __construct( 48 | public string $name, 49 | public string $stream, 50 | public array $tables = [], 51 | ) { 52 | parent::__construct(); 53 | } 54 | 55 | /** 56 | * @param string $table 57 | * @param list $columns 58 | * @return $this 59 | */ 60 | public function for(string $table, array $columns = []): static 61 | { 62 | $this->tables[$table] = $columns; 63 | return $this; 64 | } 65 | 66 | /** 67 | * @return array{ 68 | * retentionPeriod?: string, 69 | * valueCaptureType?: ChangeStreamValueCaptureType, 70 | * excludeTtlDeletes?: bool, 71 | * excludeInsert?: bool, 72 | * excludeUpdate?: bool, 73 | * excludeDelete?: bool, 74 | * } 75 | */ 76 | public function getOptions(): array 77 | { 78 | return array_filter([ 79 | 'retentionPeriod' => $this->retentionPeriod, 80 | 'valueCaptureType' => $this->valueCaptureType, 81 | 'excludeTtlDeletes' => $this->excludeTtlDeletes, 82 | 'excludeInsert' => $this->excludeInsert, 83 | 'excludeUpdate' => $this->excludeUpdate, 84 | 'excludeDelete' => $this->excludeDelete, 85 | ], static fn($v) => $v !== null); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Schema/ChangeStreamValueCaptureType.php: -------------------------------------------------------------------------------- 1 | |list $columns 28 | * @property string|null $interleaveIn 29 | * @property bool|null $nullFiltered 30 | * @property list|null $storing 31 | * @method $this interleaveIn(string $table) 32 | * @method $this nullFiltered() 33 | * @method $this storing(string[] $columns) 34 | */ 35 | class IndexDefinition extends BaseIndexDefinition 36 | { 37 | } 38 | -------------------------------------------------------------------------------- /src/Schema/IntColumnDefinition.php: -------------------------------------------------------------------------------- 1 | useSequence = $name ?? $this->createDefaultSequence(); 43 | return $this; 44 | } 45 | 46 | /** 47 | * @return string 48 | */ 49 | protected function createDefaultSequence(): string 50 | { 51 | $definition = $this->blueprint->createSequence($this->createSequenceName()); 52 | $definition->startWithCounter(random_int(1, 1000000)); 53 | return $definition->sequence; 54 | } 55 | 56 | /** 57 | * @return string 58 | */ 59 | protected function createSequenceName(): string 60 | { 61 | return $this->blueprint->getPrefix() 62 | . $this->blueprint->getTable() 63 | . '_' 64 | . $this->name 65 | . '_sequence'; 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /src/Schema/InterleaveDefinition.php: -------------------------------------------------------------------------------- 1 | 29 | */ 30 | class InterleaveDefinition extends Fluent 31 | { 32 | /** 33 | * @return $this 34 | */ 35 | public function cascadeOnDelete(): static 36 | { 37 | return $this->onDelete('cascade'); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Schema/RenameDefinition.php: -------------------------------------------------------------------------------- 1 | 29 | */ 30 | class RenameDefinition extends Fluent 31 | { 32 | /** 33 | * @param string $name 34 | * @param string $to 35 | */ 36 | public function __construct(string $name, string $to) 37 | { 38 | parent::__construct(['name' => $name, 'to' => $to]); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Schema/RowDeletionPolicyDefinition.php: -------------------------------------------------------------------------------- 1 | 30 | */ 31 | class RowDeletionPolicyDefinition extends Fluent 32 | { 33 | } 34 | -------------------------------------------------------------------------------- /src/Schema/SearchIndexDefinition.php: -------------------------------------------------------------------------------- 1 | $columns 25 | * @property string|list $partitionBy 26 | * @property list|array $orderBy 27 | * @property bool|null $sortOrderSharding 28 | * @property bool|null $disableAutomaticUidColumn 29 | * @method $this partitionBy(string|string[] $columns) 30 | * @method $this orderBy(string|string[] $columns) 31 | * @method $this sortOrderSharding(bool $toggle = true) 32 | * @method $this disableAutomaticUidColumn(bool $toggle = true) 33 | */ 34 | class SearchIndexDefinition extends IndexDefinition 35 | { 36 | /** 37 | * @return array{ sortOrderSharding?: bool, disableAutomaticUidColumn?: bool } 38 | */ 39 | public function getOptions(): array 40 | { 41 | return array_filter([ 42 | 'sortOrderSharding' => $this->sortOrderSharding, 43 | 'disableAutomaticUidColumn' => $this->disableAutomaticUidColumn, 44 | ], static fn($v) => $v !== null); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Schema/SequenceDefinition.php: -------------------------------------------------------------------------------- 1 | 35 | */ 36 | class SequenceDefinition extends Fluent 37 | { 38 | /** 39 | * @param string $name 40 | * @param string $sequence 41 | */ 42 | public function __construct(string $name, string $sequence) 43 | { 44 | parent::__construct(['name' => $name, 'sequence' => $sequence]); 45 | } 46 | 47 | /** 48 | * @return array{ sequenceKind: string, startWithCounter?: int, skipRangeMin?: int, skipRangeMax?: int } 49 | */ 50 | public function getOptions(): array 51 | { 52 | return array_filter([ 53 | 'sequenceKind' => 'bit_reversed_positive', 54 | 'startWithCounter' => $this->startWithCounter, 55 | 'skipRangeMin' => $this->skipRangeMin, 56 | 'skipRangeMax' => $this->skipRangeMax, 57 | ], static fn($v) => $v !== null); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Schema/TokenizerFunction.php: -------------------------------------------------------------------------------- 1 | default(new Expression('generate_uuid()')); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Session/SessionInfo.php: -------------------------------------------------------------------------------- 1 | 47 | */ 48 | protected array $labels; 49 | 50 | /** 51 | * @param ProtoBufSession $protobufSession 52 | */ 53 | public function __construct(ProtoBufSession $protobufSession) 54 | { 55 | $this->fullName = $protobufSession->getName(); 56 | $this->name = collect(explode('/', $protobufSession->getName()))->last() ?? 'undefined'; 57 | if (($createTime = $protobufSession->getCreateTime()) !== null) { 58 | $this->createdAt = Carbon::instance($createTime->toDateTime()); 59 | } 60 | if (($approximateLastUseTime = $protobufSession->getApproximateLastUseTime()) !== null) { 61 | $this->lastUsedAt = Carbon::instance($approximateLastUseTime->toDateTime()); 62 | } 63 | /** @var array $labels */ 64 | $labels = iterator_to_array($protobufSession->getLabels()); 65 | $this->labels = $labels; 66 | } 67 | 68 | /** 69 | * @return string 70 | */ 71 | public function getFullName(): string 72 | { 73 | return $this->fullName; 74 | } 75 | 76 | /** 77 | * @return string 78 | */ 79 | public function getName(): string 80 | { 81 | return $this->name; 82 | } 83 | 84 | /** 85 | * @return Carbon|null 86 | */ 87 | public function getCreatedAt(): ?Carbon 88 | { 89 | return $this->createdAt; 90 | } 91 | 92 | /** 93 | * @return Carbon|null 94 | */ 95 | public function getLastUsedAt(): ?Carbon 96 | { 97 | return $this->lastUsedAt; 98 | } 99 | 100 | /** 101 | * @return array 102 | */ 103 | public function getLabels(): array 104 | { 105 | return $this->labels; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/SpannerServiceProvider.php: -------------------------------------------------------------------------------- 1 | , 41 | * } 42 | */ 43 | class SpannerServiceProvider extends ServiceProvider 44 | { 45 | /** 46 | * @return void 47 | */ 48 | public function register(): void 49 | { 50 | $this->app->resolving('db', function (DatabaseManager $db) { 51 | $db->extend('spanner', function (array $config, string $name): Connection { 52 | return $this->createSpannerConnection($this->parseConfig($config, $name)); 53 | }); 54 | }); 55 | 56 | if ($this->app->runningInConsole()) { 57 | $this->commands([ 58 | CooldownCommand::class, 59 | SessionsCommand::class, 60 | WarmupCommand::class, 61 | ]); 62 | } 63 | } 64 | 65 | public function boot(): void 66 | { 67 | $this->closeSessionAfterEachQueueJob(); 68 | } 69 | 70 | /** 71 | * @param TConfig $config 72 | * @return Connection 73 | */ 74 | protected function createSpannerConnection(array $config): Connection 75 | { 76 | return new Connection( 77 | $config['instance'], 78 | $config['database'], 79 | $config['prefix'], 80 | $config, 81 | $this->createAuthCache($config), 82 | $this->createSessionPool($config), 83 | ); 84 | } 85 | 86 | /** 87 | * @param array $config 88 | * @return TConfig 89 | */ 90 | protected function parseConfig(array $config, string $name): array 91 | { 92 | if ($name === '_auth') { 93 | throw new LogicException('Connection name "_auth" is reserved.'); 94 | } 95 | 96 | /** 97 | * @var TConfig 98 | */ 99 | return $config + [ 100 | 'prefix' => '', 101 | 'name' => $name, 102 | 'cache_path' => null, 103 | 'session_pool' => [], 104 | ]; 105 | } 106 | 107 | /** 108 | * @param array{ name: string, cache_path: string|null } $config 109 | * @return AdapterInterface 110 | */ 111 | protected function createAuthCache(array $config): AdapterInterface 112 | { 113 | return $this->getCacheAdapter($config['name'] . '_auth', $config['cache_path']); 114 | } 115 | 116 | /** 117 | * @param array{ name: string, cache_path: string|null, session_pool: array } $config 118 | * @return SessionPoolInterface 119 | */ 120 | protected function createSessionPool(array $config): SessionPoolInterface 121 | { 122 | return new CacheSessionPool( 123 | $this->getCacheAdapter($config['name'] . '_sessions', $config['cache_path']), 124 | $config['session_pool'], 125 | ); 126 | } 127 | 128 | /** 129 | * @param string $namespace 130 | * @param string|null $path 131 | * @return AdapterInterface 132 | */ 133 | protected function getCacheAdapter(string $namespace, ?string $path): AdapterInterface 134 | { 135 | $path ??= $this->app->storagePath('framework/spanner'); 136 | return new FilesystemAdapter($namespace, 0, $path); 137 | } 138 | 139 | /** 140 | * @return void 141 | */ 142 | protected function closeSessionAfterEachQueueJob(): void 143 | { 144 | $this->app->resolving('queue', function (QueueManager $queue): void { 145 | $queue->after(static function (): void { 146 | foreach (DB::getConnections() as $connection) { 147 | if ($connection instanceof Connection) { 148 | $connection->disconnect(); 149 | } 150 | } 151 | }); 152 | }); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/TimestampBound/ExactStaleness.php: -------------------------------------------------------------------------------- 1 | duration = $duration; 42 | } 43 | 44 | /** 45 | * @inheritDoc 46 | */ 47 | public function transactionOptions(): array 48 | { 49 | return [ 50 | 'exactStaleness' => $this->duration, 51 | ]; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/TimestampBound/MaxStaleness.php: -------------------------------------------------------------------------------- 1 | duration = $duration; 42 | } 43 | 44 | /** 45 | * @inheritDoc 46 | */ 47 | public function transactionOptions(): array 48 | { 49 | return [ 50 | 'maxStaleness' => $this->duration, 51 | ]; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/TimestampBound/MinReadTimestamp.php: -------------------------------------------------------------------------------- 1 | timestamp = $timestamp; 39 | } 40 | 41 | /** 42 | * @inheritDoc 43 | */ 44 | public function transactionOptions(): array 45 | { 46 | return [ 47 | 'minReadTimestamp' => $this->timestamp, 48 | ]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/TimestampBound/ReadTimestamp.php: -------------------------------------------------------------------------------- 1 | timestamp = $timestamp; 39 | } 40 | 41 | /** 42 | * @inheritDoc 43 | */ 44 | public function transactionOptions(): array 45 | { 46 | return [ 47 | 'readTimestamp' => $this->timestamp, 48 | ]; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/TimestampBound/StrongRead.php: -------------------------------------------------------------------------------- 1 | true, 29 | ]; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/TimestampBound/TimestampBoundInterface.php: -------------------------------------------------------------------------------- 1 |