├── .gitignore ├── .php-cs-fixer.cache ├── .travis.yml ├── CHANGELOG.md ├── LICENCE ├── README.md ├── UPGRADE-7.0.md ├── bin └── dev │ ├── check_code.sh │ └── fix_code.sh ├── composer.json ├── composer.lock ├── examples └── expression_builder.php ├── grumphp.yml ├── phpstan.neon ├── phpunit.xml.dist ├── src ├── Client.php ├── DBAL │ ├── Expression │ │ ├── CollectionOperation.php │ │ ├── Comparison.php │ │ ├── CompositeDomain.php │ │ ├── ConversionException.php │ │ ├── CustomDomain.php │ │ ├── DomainInterface.php │ │ ├── ExpressionBuilder.php │ │ └── ExpressionBuilderAwareTrait.php │ ├── Query │ │ ├── AbstractQuery.php │ │ ├── NativeQuery.php │ │ ├── NoResultException.php │ │ ├── NoUniqueResultException.php │ │ ├── OrmQuery.php │ │ ├── QueryBuilder.php │ │ ├── QueryException.php │ │ └── QueryInterface.php │ ├── RecordManager.php │ ├── Repository │ │ ├── RecordNotFoundException.php │ │ └── RecordRepository.php │ └── Schema │ │ ├── Choice.php │ │ ├── Field.php │ │ ├── Model.php │ │ ├── Schema.php │ │ ├── SchemaException.php │ │ └── Selection.php ├── Endpoint.php └── Exception │ ├── AuthenticationException.php │ ├── ExceptionInterface.php │ ├── MissingConfigParameterException.php │ ├── RemoteException.php │ └── RequestException.php ├── tests.phpstan.neon └── tests ├── AbstractTest.php ├── Expression ├── AbstractDomainTest.php ├── ComparisonTest.php └── CompositeDomainTest.php └── Utils ├── Debugger.php ├── ObjectTester.php ├── Reflector.php └── TestDecorator.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /.idea/ 3 | .php_cs.cache 4 | .phpunit.result.cache 5 | test.php -------------------------------------------------------------------------------- /.php-cs-fixer.cache: -------------------------------------------------------------------------------- 1 | {"php":"7.4.24","version":"3.5.0:v3.5.0#333f15e07c866e33e2765e84ba1e0b88e6a3af3b","indent":" ","lineEnding":"\n","rules":{"array_syntax":true,"backtick_to_shell_exec":true,"binary_operator_spaces":true,"blank_line_before_statement":{"statements":["return"]},"braces":{"allow_single_line_anonymous_class_with_empty_body":true,"allow_single_line_closure":true},"cast_spaces":true,"class_attributes_separation":{"elements":{"method":"one"}},"class_definition":{"single_line":true},"clean_namespace":true,"concat_space":true,"echo_tag_syntax":true,"empty_loop_body":{"style":"braces"},"empty_loop_condition":true,"fully_qualified_strict_types":true,"function_typehint_space":true,"general_phpdoc_tag_rename":{"replacements":{"inheritDocs":"inheritDoc"}},"include":true,"increment_style":true,"integer_literal_case":true,"lambda_not_used_import":true,"linebreak_after_opening_tag":true,"magic_constant_casing":true,"magic_method_casing":true,"method_argument_space":{"on_multiline":"ignore"},"native_function_casing":true,"native_function_type_declaration_casing":true,"no_alias_language_construct_call":true,"no_alternative_syntax":true,"no_binary_string":true,"no_blank_lines_after_phpdoc":true,"no_empty_comment":true,"no_empty_phpdoc":true,"no_empty_statement":true,"no_extra_blank_lines":{"tokens":["case","continue","curly_brace_block","default","extra","parenthesis_brace_block","square_brace_block","switch","throw","use"]},"no_leading_namespace_whitespace":true,"no_mixed_echo_print":true,"no_multiline_whitespace_around_double_arrow":true,"no_short_bool_cast":true,"no_singleline_whitespace_before_semicolons":true,"no_spaces_around_offset":true,"no_superfluous_phpdoc_tags":{"allow_mixed":true,"allow_unused_params":true},"no_trailing_comma_in_list_call":true,"no_trailing_comma_in_singleline_array":true,"no_unneeded_control_parentheses":{"statements":["break","clone","continue","echo_print","return","switch_case","yield","yield_from"]},"no_unneeded_curly_braces":{"namespaces":true},"no_unset_cast":true,"no_unused_imports":true,"no_whitespace_before_comma_in_array":true,"normalize_index_brace":true,"object_operator_without_whitespace":true,"ordered_imports":true,"php_unit_fqcn_annotation":true,"php_unit_method_casing":true,"phpdoc_align":true,"phpdoc_annotation_without_dot":true,"phpdoc_indent":true,"phpdoc_inline_tag_normalizer":true,"phpdoc_no_access":true,"phpdoc_no_alias_tag":true,"phpdoc_no_package":true,"phpdoc_no_useless_inheritdoc":true,"phpdoc_return_self_reference":true,"phpdoc_scalar":true,"phpdoc_separation":true,"phpdoc_single_line_var_spacing":true,"phpdoc_summary":true,"phpdoc_tag_type":{"tags":{"inheritDoc":"inline"}},"phpdoc_to_comment":true,"phpdoc_trim":true,"phpdoc_trim_consecutive_blank_line_separation":true,"phpdoc_types":true,"phpdoc_types_order":{"null_adjustment":"always_last","sort_algorithm":"none"},"phpdoc_var_without_name":true,"protected_to_private":true,"semicolon_after_instruction":true,"single_class_element_per_statement":true,"single_line_comment_style":{"comment_types":["hash"]},"single_line_throw":true,"single_quote":true,"single_space_after_construct":true,"space_after_semicolon":{"remove_in_empty_for_expressions":true},"standardize_increment":true,"standardize_not_equals":true,"switch_continue_to_break":true,"trailing_comma_in_multiline":true,"trim_array_spaces":true,"types_spaces":true,"unary_operator_spaces":true,"whitespace_after_comma_in_array":true,"yoda_style":true,"blank_line_after_opening_tag":true,"compact_nullable_typehint":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"new_with_braces":true,"no_blank_lines_after_class_opening":true,"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"return_type_declaration":true,"short_scalar_cast":true,"single_blank_line_before_namespace":true,"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"visibility_required":true,"blank_line_after_namespace":true,"constant_case":true,"elseif":true,"function_declaration":true,"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"no_break_comment":true,"no_closing_tag":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_spaces_inside_parenthesis":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_import_per_statement":true,"single_line_after_imports":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true},"hashes":{"src\\Client.php":43118670,"src\\DBAL\\Expression\\CollectionOperation.php":2939472196,"src\\DBAL\\Expression\\Comparison.php":4237095711,"src\\DBAL\\Expression\\CompositeDomain.php":354536269,"src\\DBAL\\Expression\\ConversionException.php":107016403,"src\\DBAL\\Expression\\CustomDomain.php":3489925073,"src\\DBAL\\Expression\\DomainInterface.php":3877458668,"src\\DBAL\\Expression\\ExpressionBuilder.php":1657354195,"src\\DBAL\\Expression\\ExpressionBuilderAwareTrait.php":3796741222,"src\\DBAL\\Query\\AbstractQuery.php":2930363797,"src\\DBAL\\Query\\NativeQuery.php":2335259464,"src\\DBAL\\Query\\NoResultException.php":1860820185,"src\\DBAL\\Query\\NoUniqueResultException.php":1183675382,"src\\DBAL\\Query\\OrmQuery.php":632474652,"src\\DBAL\\Query\\QueryBuilder.php":3685258252,"src\\DBAL\\Query\\QueryException.php":754063031,"src\\DBAL\\Query\\QueryInterface.php":3966439455,"src\\DBAL\\RecordManager.php":1113792547,"src\\DBAL\\Repository\\RecordNotFoundException.php":3899077087,"src\\DBAL\\Repository\\RecordRepository.php":377204786,"src\\DBAL\\Schema\\Choice.php":2356126375,"src\\DBAL\\Schema\\Field.php":2188356149,"src\\DBAL\\Schema\\Model.php":145712388,"src\\DBAL\\Schema\\Schema.php":240188129,"src\\DBAL\\Schema\\SchemaException.php":195684899,"src\\DBAL\\Schema\\Selection.php":1041914487,"src\\Endpoint.php":1412879540,"src\\Exception\\AuthenticationException.php":2083575707,"src\\Exception\\ExceptionInterface.php":141028630,"src\\Exception\\MissingConfigParameterException.php":3577118993,"src\\Exception\\RemoteException.php":2392187581,"src\\Exception\\RequestException.php":1948041852}} -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 7.2 5 | - 7.3 6 | - 7.4 7 | 8 | before_install: 9 | - composer self-update 10 | - composer install 11 | 12 | install: 13 | - php -d memory_limit=-1 $(phpenv which composer) install --no-suggest --prefer-dist -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | 7.0 5 | --- 6 | 7 | * Marked client ORM built-in methods as deprecated (no BC) 8 | * Added RemoteException with message and XML trace. 9 | * Added record manager that allows to manage models. 10 | * Added record repository that allows to execute and isolate queries for a dedicated model. 11 | * Added query builder and ORM query that allows to create queries easily in OOP context. 12 | * Added objects and iterable support for the expression builder. 13 | * Added record manager schema to get model names and metadata. 14 | * Fixed method ```count()```and added method ```countAll()``` 15 | ([Issue 8](#https://github.com/Ang3/php-odoo-api-client/issues/8)). 16 | 17 | 6.1 18 | === 19 | 20 | - Replaced package [darkaonline/ripcord](https://packagist.org/packages/DarkaOnLine/Ripcord) by 21 | [ang3/php-xmlrpc-client](https://packagist.org/packages/ang3/php-xmlrpc-client). 22 | - Implemented interface ```Ang3\Component\Odoo\Exception\ExceptionInterface``` for all client exceptions. 23 | - Fixed methods ```read()``` for integers or arrays ([Issue 6](https://github.com/Ang3/php-odoo-api-client/issues/6)). 24 | - Fixed methods when argument ```$criteria``` can be NULL 25 | - Fixed logging. 26 | - Deleted useless files and updated ```.gitignore``` 27 | 28 | 6.0 29 | === 30 | 31 | - Removed dependency of package [ang3/php-dev-binaries](https://packagist.org/packages/ang3/php-dev-binaries). 32 | - Added methods ```searchOne``` and ```searchAll```. 33 | - Back to package [darkaonline/ripcord](https://packagist.org/packages/DarkaOnLine/Ripcord). 34 | - Removed XML-RPC client. 35 | - Removed remote exception. 36 | - Removed trace back feature. -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Joanis Rouanet 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | PHP Odoo API client 2 | =================== 3 | 4 | [![Build Status](https://travis-ci.org/Ang3/php-odoo-api-client.svg?branch=master)](https://travis-ci.org/Ang3/php-odoo-api-client) 5 | [![Latest Stable Version](https://poser.pugx.org/ang3/php-odoo-api-client/v/stable)](https://packagist.org/packages/ang3/php-odoo-api-client) 6 | [![Latest Unstable Version](https://poser.pugx.org/ang3/php-odoo-api-client/v/unstable)](https://packagist.org/packages/ang3/php-odoo-api-client) 7 | [![Total Downloads](https://poser.pugx.org/ang3/php-odoo-api-client/downloads)](https://packagist.org/packages/ang3/php-odoo-api-client) 8 | 9 | Odoo API client using 10 | [XML-RPC Odoo ORM External API](https://www.odoo.com/documentation/12.0/webservices/odoo.html). It allows 11 | you call your odoo instance and manage records easily. 12 | 13 | **You are reading the documentation of version ```7.0```, if your version is older, please read 14 | [this documentation (6.1.3)](https://github.com/Ang3/php-odoo-api-client/tree/v6.1.3).** 15 | Please see the file [UPGRADE-7.0.md](https://github.com/Ang3/php-odoo-api-client/blob/7.0/UPGRADE-7.0.md) 16 | to upgrade your version easily. 17 | 18 | **Main features** 19 | 20 | - Authentication ```<7.0``` 21 | - Basic XML-RPC calls ```<7.0``` 22 | - Expression builder ```<7.0``` 23 | - Database Abstraction Layer (DBAL) ```>=7.0``` 24 | - Record manager 25 | - Repositories` 26 | - Query builder 27 | 28 | **Good to know** 29 | 30 | If you are in Symfony application you should be interested in the bundle 31 | [ang3/odoo-bundle](https://github.com/Ang3/odoo-bundle) (client integration). 32 | 33 | Requirements 34 | ============ 35 | 36 | - The PHP extension ```php-xmlrpc``` must be enabled. 37 | 38 | | Odoo server | Compatibility | Comment | 39 | | --- | --- | --- | 40 | | newer | Unknown | Needs feddback | 41 | | v13.0 | Yes | Some Odoo model names changed (e.g account.invoice > account.move) | 42 | | v12.0 | Yes | First tested version | 43 | | < v12 | Unknown | Needs feddback | 44 | 45 | Installation 46 | ============ 47 | 48 | Open a command console, enter your project directory and execute the 49 | following command to download the latest stable version of the client: 50 | 51 | ```console 52 | $ composer require ang3/php-odoo-api-client 53 | ``` 54 | 55 | This command requires you to have Composer installed globally, as explained 56 | in the [installation chapter](https://getcomposer.org/doc/00-intro.md) 57 | of the Composer documentation. 58 | 59 | Basic usage 60 | =========== 61 | 62 | First, you have to create a client instance: 63 | 64 | ```php 65 | ', '', '', '', $logger = null); 73 | 74 | // Option 2 : by calling the static method ::createFromConfig() with configuration as array 75 | $client = Client::createFromConfig([ 76 | 'url' => '', 77 | 'database' => '', 78 | 'username' => '', 79 | 'password' => '', 80 | ], $logger = null); 81 | ``` 82 | 83 | Exceptions: 84 | - ```Ang3\Component\Odoo\Exception\MissingConfigParameterException``` when a required parameter is missing 85 | from the static method ```createFromConfig()```. 86 | 87 | Then, make your call: 88 | 89 | ```php 90 | $result = $client->call($name, $method, $parameters = [], $options = []); 91 | ``` 92 | 93 | Exceptions: 94 | - ```Ang3\Component\Odoo\Exception\AuthenticationException``` when authentication failed. 95 | - ```Ang3\Component\Odoo\Exception\RequestException``` when request failed. 96 | 97 | These previous exception can be thrown by all methods of the client. 98 | 99 | DBAL (Database Abstraction Layer) 100 | ================================= 101 | 102 | First of all, Odoo is a database. Each "model" is a table and has its own fields. 103 | 104 | > DBAL features was added in version ```7.0``` - If your version is older, please use the built-in 105 | ORM methods of the client like explained in the 106 | > [dedicated documentation](https://github.com/Ang3/php-odoo-api-client/tree/v6.1.3): 107 | be aware that these client ORM methods are deprecated since version ```7.0```. 108 | 109 | Record manager 110 | -------------- 111 | 112 | The client provides a record manager to manage records of your Odoo models. 113 | 114 | You can get the related manager of the client like below: 115 | 116 | ```php 117 | $recordManager = $client->getRecordManager(); 118 | ``` 119 | 120 | You can also create your own with a client instance: 121 | 122 | ```php 123 | use Ang3\Component\Odoo\DBAL\RecordManager; 124 | 125 | /** @var \Ang3\Component\Odoo\Client $myClient */ 126 | $recordManager = new RecordManager($myClient); 127 | ``` 128 | 129 | ### Built-in ORM methods 130 | 131 | Here is all built-in ORM methods provided by the record manager: 132 | 133 | ```php 134 | use Ang3\Component\Odoo\DBAL\Expression\DomainInterface; 135 | 136 | /** 137 | * Create a new record. 138 | * 139 | * @return int the ID of the new record 140 | */public function create(string $modelName, array $data): int; 141 | 142 | /** 143 | * Update record(s). 144 | * 145 | * NB: It is not currently possible to perform “computed” updates (by criteria). 146 | * To do it, you have to perform a search then an update with search result IDs. 147 | * 148 | * @param array|int $ids 149 | */ 150 | public function update(string $modelName, $ids, array $data = []): void; 151 | 152 | /** 153 | * Delete record(s). 154 | * 155 | * NB: It is not currently possible to perform “computed” deletes (by criteria). 156 | * To do it, you have to perform a search then a delete with search result IDs. 157 | * 158 | * @param array|int $ids 159 | */ 160 | public function delete(string $modelName, $ids): void; 161 | 162 | /** 163 | * Search one ID of record by criteria. 164 | */ 165 | public function searchOne(string $modelName, ?DomainInterface $criteria): ?int; 166 | 167 | /** 168 | * Search all ID of record(s). 169 | * 170 | * @return int[] 171 | */ 172 | public function searchAll(string $modelName, array $orders = [], int $limit = null, int $offset = null): array; 173 | 174 | /** 175 | * Search ID of record(s) by criteria. 176 | * 177 | * @return int[] 178 | */ 179 | public function search(string $modelName, ?DomainInterface $criteria = null, array $orders = [], int $limit = null, int $offset = null): array; 180 | 181 | /** 182 | * Find ONE record by ID. 183 | * 184 | * @throws RecordNotFoundException when the record was not found 185 | */ 186 | public function read(string $modelName, int $id, array $fields = []): array; 187 | 188 | /** 189 | * Find ONE record by ID. 190 | */ 191 | public function find(string $modelName, int $id, array $fields = []): ?array; 192 | 193 | /** 194 | * Find ONE record by criteria. 195 | */ 196 | public function findOneBy(string $modelName, ?DomainInterface $criteria = null, array $fields = [], array $orders = [], int $offset = null): ?array; 197 | 198 | /** 199 | * Find all records. 200 | * 201 | * @return array[] 202 | */ 203 | public function findAll(string $modelName, array $fields = [], array $orders = [], int $limit = null, int $offset = null): array; 204 | 205 | /** 206 | * Find record(s) by criteria. 207 | * 208 | * @return array[] 209 | */ 210 | public function findBy(string $modelName, ?DomainInterface $criteria = null, array $fields = [], array $orders = [], int $limit = null, int $offset = null): array; 211 | 212 | /** 213 | * Check if a record exists. 214 | */ 215 | public function exists(string $modelName, int $id): bool; 216 | 217 | /** 218 | * Count number of all records for the model. 219 | */ 220 | public function countAll(string $modelName): int; 221 | 222 | /** 223 | * Count number of records for a model and criteria. 224 | */ 225 | public function count(string $modelName, ?DomainInterface $criteria = null): int; 226 | ``` 227 | 228 | For ```$criteria``` in select/search queries and ```$data``` for data writing context, please read the section 229 | [Expression builder](#expression-builder). 230 | 231 | Schema 232 | ------ 233 | 234 | You can get the schema of your Odoo database by calling the getter method 235 | ```RecordManager::getSchema()```: 236 | 237 | ```php 238 | /** @var \Ang3\Component\Odoo\DBAL\Schema\Schema $schema */ 239 | $schema = $recordManager->getSchema(); 240 | ``` 241 | 242 | The schema helps you to get all model names or get metadata of a model. 243 | 244 | ### Get all model names 245 | 246 | ```php 247 | /** @var string[] $modelNames */ 248 | $modelNames = $schema->getModelNames(); 249 | ``` 250 | 251 | ### Get model metadata 252 | 253 | ```php 254 | /** @var \Ang3\Component\Odoo\DBAL\Schema\Model $model */ 255 | $model = $schema->getModel('res.company'); 256 | ``` 257 | 258 | An exception of type ```Ang3\Component\Odoo\DBAL\Schema\SchemaException``` is thrown if the model 259 | does not exist. 260 | 261 | Query builder 262 | ------------- 263 | 264 | It helps you to create queries easily by chaining helpers methods (like Doctrine for SQL databases). 265 | 266 | ### Create a query builder 267 | 268 | ```php 269 | /** @var string|null $modelName */ 270 | $queryBuilder = $recordManager->createQueryBuilder($modelName); 271 | ``` 272 | 273 | The variable ```$modelName``` represents the target model of your query (clause ```from```). 274 | 275 | ### Build your query 276 | 277 | Here is a complete list of helper methods available in ```QueryBuilder```: 278 | 279 | ```php 280 | /** 281 | * Defines the query of type "SELECT" with selected fields. 282 | * No fields selected = all fields returned. 283 | * 284 | * @param array|string|null $fields 285 | */ 286 | public function select($fields = null): self; 287 | 288 | /** 289 | * Defines the query of type "SEARCH". 290 | */ 291 | public function search(): self; 292 | 293 | /** 294 | * Defines the query of type "INSERT". 295 | */ 296 | public function insert(): self; 297 | 298 | /** 299 | * Defines the query of type "UPDATE" with ids of records to update. 300 | * 301 | * @param int[] $ids 302 | */ 303 | public function update(array $ids): self; 304 | 305 | /** 306 | * Defines the query of type "DELETE" with ids of records to delete. 307 | */ 308 | public function delete(array $ids): self; 309 | 310 | /** 311 | * Adds a field to select. 312 | * 313 | * @throws LogicException when the type of the query is not "SELECT". 314 | */ 315 | public function addSelect(string $fieldName): self; 316 | 317 | /** 318 | * Gets selected fields. 319 | */ 320 | public function getSelect(): array; 321 | 322 | /** 323 | * Sets the target model name. 324 | */ 325 | public function from(string $modelName): self; 326 | 327 | /** 328 | * Gets the target model name of the query. 329 | */ 330 | public function getFrom(): ?string; 331 | 332 | /** 333 | * Sets target IDs in case of query of type "UPDATE" or "DELETE". 334 | * 335 | * @throws LogicException when the type of the query is not "UPDATE" nor "DELETE". 336 | */ 337 | public function setIds(array $ids): self; 338 | 339 | /** 340 | * Adds target ID in case of query of type "UPDATE" or "DELETE". 341 | * 342 | * @throws LogicException when the type of the query is not "UPDATE" nor "DELETE". 343 | */ 344 | public function addId(int $id): self; 345 | 346 | /** 347 | * Sets field values in case of query of type "INSERT" or "UPDATE". 348 | * 349 | * @throws LogicException when the type of the query is not "INSERT" nor "UPDATE". 350 | */ 351 | public function setValues(array $values = []): self; 352 | 353 | /** 354 | * Set a field value in case of query of type "INSERT" or "UPDATE". 355 | * 356 | * @param mixed $value 357 | * 358 | * @throws LogicException when the type of the query is not "INSERT" nor "UPDATE". 359 | */ 360 | public function set(string $fieldName, $value): self; 361 | 362 | /** 363 | * Gets field values set in case of query of type "INSERT" or "UPDATE". 364 | */ 365 | public function getValues(): array; 366 | 367 | /** 368 | * Sets criteria for queries of type "SELECT" and "SEARCH". 369 | * 370 | * @throws LogicException when the type of the query is not "SELECT" not "SEARCH". 371 | */ 372 | public function where(?DomainInterface $domain = null): self; 373 | 374 | /** 375 | * Takes the WHERE clause and adds a node with logical operator AND. 376 | * 377 | * @throws LogicException when the type of the query is not "SELECT" nor "SEARCH". 378 | */ 379 | public function andWhere(DomainInterface $domain): self; 380 | 381 | /** 382 | * Takes the WHERE clause and adds a node with logical operator OR. 383 | * 384 | * @throws LogicException when the type of the query is not "SELECT" nor "SEARCH". 385 | */ 386 | public function orWhere(DomainInterface $domain): self; 387 | 388 | /** 389 | * Gets the WHERE clause. 390 | */ 391 | public function getWhere(): ?DomainInterface; 392 | 393 | /** 394 | * Sets orders. 395 | */ 396 | public function setOrders(array $orders = []): self; 397 | 398 | /** 399 | * Clears orders and adds one. 400 | */ 401 | public function orderBy(string $fieldName, bool $isAsc = true): self; 402 | 403 | /** 404 | * Adds order. 405 | * 406 | * @throws LogicException when the query type is not valid. 407 | */ 408 | public function addOrderBy(string $fieldName, bool $isAsc = true): self; 409 | 410 | /** 411 | * Gets ordered fields. 412 | */ 413 | public function getOrders(): array; 414 | 415 | /** 416 | * Sets the max results of the query (limit). 417 | */ 418 | public function setMaxResults(?int $maxResults): self; 419 | 420 | /** 421 | * Gets the max results of the query. 422 | */ 423 | public function getMaxResults(): ?int; 424 | 425 | /** 426 | * Sets the first results of the query (offset). 427 | */ 428 | public function setFirstResult(?int $firstResult): self; 429 | 430 | /** 431 | * Gets the first results of the query. 432 | */ 433 | public function getFirstResult(): ?int; 434 | ``` 435 | 436 | Then, build your query like below: 437 | 438 | ```php 439 | $query = $queryBuilder->getQuery(); 440 | ``` 441 | 442 | Your query is an instance of ```Ang3\Component\Odoo\Query\OrmQuery```. 443 | 444 | ### Execute your query 445 | 446 | You can get/count results or execute insert/update/delete by differents ways depending on the query type. 447 | 448 | ```php 449 | /** 450 | * Counts the number of records from parameters. 451 | * Allowed methods: SEARCH, SEARCH_READ. 452 | * 453 | * @throws QueryException on invalid query method. 454 | */ 455 | public function count(): int; 456 | 457 | /** 458 | * Gets just ONE scalar result. 459 | * Allowed methods: SEARCH, SEARCH_READ. 460 | * 461 | * @return bool|int|float|string 462 | * 463 | * @throws NoUniqueResultException on no unique result 464 | * @throws NoResultException on no result 465 | * @throws QueryException on invalid query method. 466 | */ 467 | public function getSingleScalarResult(); 468 | 469 | /** 470 | * Gets one or NULL scalar result. 471 | * Allowed methods: SEARCH, SEARCH_READ. 472 | * 473 | * @return bool|int|float|string|null 474 | * 475 | * @throws NoUniqueResultException on no unique result 476 | * @throws QueryException on invalid query method. 477 | */ 478 | public function getOneOrNullScalarResult(); 479 | 480 | /** 481 | * Gets a list of scalar result. 482 | * Allowed methods: SEARCH, SEARCH_READ. 483 | * 484 | * @throws QueryException on invalid query method. 485 | * 486 | * @return array 487 | */ 488 | public function getScalarResult(): array; 489 | 490 | /** 491 | * Gets one row. 492 | * Allowed methods: SEARCH, SEARCH_READ. 493 | * 494 | * @throws NoUniqueResultException on no unique result 495 | * @throws NoResultException on no result 496 | * @throws QueryException on invalid query method. 497 | */ 498 | public function getSingleResult(): array; 499 | 500 | /** 501 | * Gets one or NULL row. 502 | * Allowed methods: SEARCH, SEARCH_READ. 503 | * 504 | * @throws NoUniqueResultException on no unique result 505 | * @throws QueryException on invalid query method. 506 | */ 507 | public function getOneOrNullResult(): ?array; 508 | 509 | /** 510 | * Gets all result rows. 511 | * Allowed methods: SEARCH, SEARCH_READ. 512 | * 513 | * @throws QueryException on invalid query method. 514 | */ 515 | public function getResult(): array; 516 | 517 | /** 518 | * Execute the query. 519 | * Allowed methods: all. 520 | * 521 | * @return mixed 522 | */ 523 | public function execute(); 524 | ``` 525 | 526 | Repositories 527 | ============ 528 | 529 | Sometimes, you would want to keep your queries in memory to reuse it in your code. To do it, you should use 530 | a repository. A repository is a class that helps you to isolate queries for a dedicated model. 531 | 532 | For example, let's create the repository for your companies and define a query to get all french companies: 533 | 534 | ```php 535 | namespace App\Odoo\Repository; 536 | 537 | use Ang3\Component\Odoo\DBAL\RecordManager; 538 | use Ang3\Component\Odoo\DBAL\Repository\RecordRepository; 539 | 540 | class CompanyRepository extends RecordRepository 541 | { 542 | public function __construct(RecordManager $recordManager) 543 | { 544 | parent::__construct($recordManager, 'res.company'); 545 | } 546 | 547 | public function findFrenchCompanies(): array 548 | { 549 | return $this 550 | ->createQueryBuilder() 551 | ->select('name') 552 | ->where($this->expr()->eq('country_id.code', 'FR')) 553 | ->getQuery() 554 | ->getResult(); 555 | } 556 | } 557 | ``` 558 | 559 | Note that Odoo will always return the record ID in the result, even if you didn't select it explicitly. 560 | 561 | Each repository is registered inside the record manager on construct. 562 | That's why you can retrieve your repository directly from the record manager: 563 | 564 | ```php 565 | /** @var \App\Odoo\Repository\CompanyRepository $companyRepository */ 566 | $companyRepository = $recordManager->getRepository('res.company'); 567 | ``` 568 | 569 | If no repository exists for a model, the default repository ```Ang3\Component\Odoo\DBAL\Repository\RecordRepository``` 570 | is used. Last but not least, all repositories are stored into the related record manager to avoid creating multiple 571 | instances of same repository. 572 | 573 | Expression builder 574 | ================== 575 | 576 | There are two kinds of expressions : ```domains``` for criteria 577 | and ```collection operations``` in data writing context. 578 | Odoo has its own array format for those expressions. 579 | The aim of the expression builder is to provide some 580 | helper methods to simplify your programmer's life. 581 | 582 | Here is an example of how to get a builder from a client or record manager: 583 | 584 | ```php 585 | $expr = $clientOrRecordManager->expr(); 586 | // or $expr = $clientOrRecordManager->getExpressionBuilder(); 587 | ``` 588 | 589 | You can still use the expression builder as standalone by creating a new instance: 590 | 591 | ```php 592 | use Ang3\Component\Odoo\DBAL\Expression\ExpressionBuilder; 593 | 594 | $expr = new ExpressionBuilder(); 595 | ``` 596 | 597 | Domains 598 | ------- 599 | 600 | For all **select/search/count** queries, 601 | Odoo is waiting for an array of [domains](https://www.odoo.com/documentation/13.0/reference/orm.html#search-domains) 602 | with a *polish notation* for logical operations (```AND```, ```OR``` and ```NOT```). 603 | 604 | It could be quickly ugly to do a complex domain, but don't worry the builder makes all 605 | for you. :-) 606 | 607 | Each domain builder method creates an instance of ```Ang3\Component\Odoo\Expression\DomainInterface```. 608 | The only one method of this interface is ```toArray()``` to get a normalized array of the expression. 609 | 610 | To illustrate how to work with it, here is an example using ```ExpressionBuilder``` helper methods: 611 | 612 | ```php 613 | // Get the expression builder 614 | $expr = $recordManager->expr(); 615 | 616 | $result = $recordManager->findBy('model_name', $expr->andX( // Logical node "AND" 617 | $expr->gte('id', 10), // id >= 10 618 | $expr->lte('id', 100), // id <= 10 619 | )); 620 | ``` 621 | 622 | Of course, you can nest logical nodes: 623 | 624 | ```php 625 | $result = $recordManager->findBy('model_name', $expr->andX( 626 | $expr->orX( 627 | $expr->eq('A', 1), 628 | $expr->eq('B', 1) 629 | ), 630 | $expr->orX( 631 | $expr->eq('C', 1), 632 | $expr->eq('D', 1), 633 | $expr->eq('E', 1) 634 | ) 635 | )); 636 | ``` 637 | 638 | Internally, the client formats automatically all domains by calling the special builder 639 | method ```normalizeDomains()```. 640 | 641 | Here is a complete list of helper methods available in ```ExpressionBuilder``` for domain expressions: 642 | 643 | ```php 644 | /** 645 | * Create a logical operation "AND". 646 | */ 647 | public function andX(DomainInterface ...$domains): CompositeDomain; 648 | 649 | /** 650 | * Create a logical operation "OR". 651 | */ 652 | public function orX(DomainInterface ...$domains): CompositeDomain; 653 | 654 | /** 655 | * Create a logical operation "NOT". 656 | */ 657 | public function notX(DomainInterface ...$domains): CompositeDomain; 658 | 659 | /** 660 | * Check if the field is EQUAL TO the value. 661 | * 662 | * @param mixed $value 663 | */ 664 | public function eq(string $fieldName, $value): Comparison; 665 | 666 | /** 667 | * Check if the field is NOT EQUAL TO the value. 668 | * 669 | * @param mixed $value 670 | */ 671 | public function neq(string $fieldName, $value): Comparison; 672 | 673 | /** 674 | * Check if the field is UNSET OR EQUAL TO the value. 675 | * 676 | * @param mixed $value 677 | */ 678 | public function ueq(string $fieldName, $value): Comparison; 679 | 680 | /** 681 | * Check if the field is LESS THAN the value. 682 | * 683 | * @param mixed $value 684 | */ 685 | public function lt(string $fieldName, $value): Comparison; 686 | 687 | /** 688 | * Check if the field is LESS THAN OR EQUAL the value. 689 | * 690 | * @param mixed $value 691 | */ 692 | public function lte(string $fieldName, $value): Comparison; 693 | 694 | /** 695 | * Check if the field is GREATER THAN the value. 696 | * 697 | * @param mixed $value 698 | */ 699 | public function gt(string $fieldName, $value): Comparison; 700 | 701 | /** 702 | * Check if the field is GREATER THAN OR EQUAL the value. 703 | * 704 | * @param mixed $value 705 | */ 706 | public function gte(string $fieldName, $value): Comparison; 707 | 708 | /** 709 | * Check if the variable is LIKE the value. 710 | * 711 | * An underscore _ in the pattern stands for (matches) any single character 712 | * A percent sign % matches any string of zero or more characters. 713 | * 714 | * If $strict is set to FALSE, the value pattern is "%value%" (automatically wrapped into signs %). 715 | * 716 | * @param mixed $value 717 | */ 718 | public function like(string $fieldName, $value, bool $strict = false, bool $caseSensitive = true): Comparison; 719 | 720 | /** 721 | * Check if the field is IS NOT LIKE the value. 722 | * 723 | * @param mixed $value 724 | */ 725 | public function notLike(string $fieldName, $value, bool $caseSensitive = true): Comparison; 726 | 727 | /** 728 | * Check if the field is IN values list. 729 | */ 730 | public function in(string $fieldName, array $values = []): Comparison; 731 | 732 | /** 733 | * Check if the field is NOT IN values list. 734 | */ 735 | public function notIn(string $fieldName, array $values = []): Comparison; 736 | ``` 737 | 738 | Collection operations 739 | --------------------- 740 | 741 | In data writing context with queries of type **insert/update**, Odoo allows you to manage ***toMany** collection 742 | fields with special commands. 743 | 744 | Please read the [ORM documentation](https://www.odoo.com/documentation/13.0/reference/orm.html#openerp-models-relationals-format) 745 | to known what we are talking about. 746 | 747 | The expression builder provides helper methods to build a well-formed *operation command*: 748 | each operation method returns an instance of ```Ang3\Component\Odoo\DBAL\Expression\CollectionOperation```. 749 | Like domains, the only one method of this interface is ```toArray()``` to get a normalized array of the expression. 750 | 751 | To illustrate how to work with operations, here is an example using ```ExpressionBuilder``` helper methods: 752 | 753 | ```php 754 | // Get the expression builder 755 | $expr = $recordManager->expr(); 756 | 757 | // Prepare data for a new record 758 | $data = [ 759 | 'foo' => 'bar', 760 | 'bar_ids' => [ // Field of type "manytoMany" 761 | $expr->addRecord(3), // Add the record of ID 3 to the set 762 | $expr->createRecord([ // Create a new sub record and add it to the set 763 | 'bar' => 'baz' 764 | // ... 765 | ]) 766 | ] 767 | ]; 768 | 769 | $result = $recordManager->create('model_name', $data); 770 | ``` 771 | 772 | Internally, the client formats automatically the whole query parameters for all writing methods 773 | (```create``` and ```update```) by calling the special builder 774 | method ```normalizeData()```. 775 | 776 | Here is a complete list of helper methods available in ```ExpressionBuilder``` for operation expressions: 777 | 778 | ```php 779 | /** 780 | * Adds a new record created from data. 781 | */ 782 | public function createRecord(array $data): CollectionOperation; 783 | 784 | /** 785 | * Updates an existing record of id $id with data. 786 | * /!\ Can not be used in record CREATE query. 787 | */ 788 | public function updateRecord(int $id, array $data): CollectionOperation; 789 | 790 | /** 791 | * Adds an existing record of id $id to the collection. 792 | */ 793 | public function addRecord(int $id): CollectionOperation; 794 | 795 | /** 796 | * Removes the record of id $id from the collection, but does not delete it. 797 | * /!\ Can not be used in record CREATE query. 798 | */ 799 | public function removeRecord(int $id): CollectionOperation; 800 | 801 | /** 802 | * Removes the record of id $id from the collection, then deletes it from the database. 803 | * /!\ Can not be used in record CREATE query. 804 | */ 805 | public function deleteRecord(int $id): CollectionOperation; 806 | 807 | /** 808 | * Replaces all existing records in the collection by the $ids list, 809 | * Equivalent to using the command "clear" followed by a command "add" for each id in $ids. 810 | */ 811 | public function replaceRecords(array $ids = []): CollectionOperation; 812 | 813 | /** 814 | * Removes all records from the collection, equivalent to using the command "remove" on every record explicitly. 815 | * /!\ Can not be used in record CREATE query. 816 | */ 817 | public function clearRecords(): CollectionOperation; 818 | ``` 819 | 820 | Data support 821 | ------------ 822 | 823 | - Scalar values are unchanged 824 | - Arrays recursive conversion 825 | - Objects of type ```\DateTimeInterface``` are automatically formatted into string in UTC timezone 826 | - Iterable/generator are fetched into an array 827 | - Non-iterable values are automatically casted to string 828 | (so any non-supported objects must define the method ```__toString()```) 829 | 830 | Resources 831 | ========= 832 | 833 | - [CHANGELOG.md](CHANGELOG.md) -------------------------------------------------------------------------------- /UPGRADE-7.0.md: -------------------------------------------------------------------------------- 1 | UPGRADE FROM 6.x to 7.0 2 | ======================= 3 | 4 | No binary compatibility break (BC break). 5 | 6 | You should upgrade your version without troubles, 7 | except some deprecations messages (see [Client](#client) log). 8 | 9 | Client 10 | ------ 11 | 12 | - Marked all ORM built-in methods as deprecated 13 | - **Use the record manager instead.** 14 | - Fix [Issue 8](https://github.com/Ang3/php-odoo-api-client/issues/8) 15 | - Fixed method ```count()``` without criteria. 16 | - Added deprecated shortcut method ```countAll()```. 17 | 18 | Endpoint 19 | -------- 20 | 21 | - Added ```RemoteException``` with message and XML trace. 22 | - This exception extends ```RequestException``` 23 | 24 | DBAL 25 | ---- 26 | 27 | - Added record manager to manage models. 28 | - Added schema to get model names and metadata. 29 | - Added query builder and ORM query that allows to create queries easily in OOP context. 30 | - Added record repository that allows to execute and isolate queries for a dedicated model. 31 | 32 | Expression builder 33 | ------------------ 34 | 35 | - Implemented non-scalar values support: 36 | - Dates into string 37 | - Iterable / Generator into array 38 | - Object into string -------------------------------------------------------------------------------- /bin/dev/check_code.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # shellcheck disable=SC2039 4 | echo -e "\033[33;1m" 5 | echo -e "Checking code" 6 | echo -e "=============\033[0m" 7 | echo 8 | 9 | if [ -z "$1" ] 10 | then 11 | directory='src' 12 | echo -e "No directory specified (default: src)." 13 | echo 14 | else 15 | directory=$1 16 | echo -e "Level:" $1 17 | echo 18 | fi 19 | 20 | if [ -z "$2" ] 21 | then 22 | level=7 23 | echo -e "No level specified (default: 7 [max])." 24 | echo 25 | else 26 | level=$2 27 | echo -e "Level:" $2 28 | echo 29 | fi 30 | 31 | if [ $directory = "src" ] 32 | then 33 | config_file='phpstan.neon' 34 | else 35 | config_file='phpstan.'$directory'.neon' 36 | fi 37 | 38 | echo -e "Config file:" $config_file 39 | echo 40 | 41 | vendor/bin/phpstan analyse $directory -c $config_file -l $level -vvv -------------------------------------------------------------------------------- /bin/dev/fix_code.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # shellcheck disable=SC2039 4 | echo -e "\033[33;1m" 5 | echo -e "Fixing code" 6 | echo -e "===========\033[0m" 7 | echo 8 | 9 | if [ -z "$1" ] 10 | then 11 | directory='src' 12 | echo "No directory specified (default: src)." 13 | echo 14 | else 15 | directory=$1 16 | echo "Directory:" $directory 17 | echo 18 | fi 19 | 20 | vendor/bin/php-cs-fixer -v fix $directory --rules='{"@Symfony": true}' -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ang3/php-odoo-api-client", 3 | "type": "component", 4 | "description": "Odoo API client", 5 | "keywords": ["odoo", "api", "client", "domain", "operation", "dbal", "query", "builder", "repository", "builder", "12", "13"], 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Joanis ROUANET", 10 | "email": "joanis.ang3@gmail.com", 11 | "homepage": "https://github.com/Ang3" 12 | } 13 | ], 14 | "autoload": { 15 | "psr-4": { 16 | "Ang3\\Component\\Odoo\\": "src/", 17 | "Ang3\\Component\\Odoo\\Tests\\": "tests/" 18 | } 19 | }, 20 | "require": { 21 | "php": ">=7.2", 22 | "ext-json": "*", 23 | "ang3/php-xmlrpc-client": "^1.0.2", 24 | "psr/log": "^1.1|^2.0|^3.0" 25 | }, 26 | "require-dev": { 27 | "friendsofphp/php-cs-fixer": "^3.0", 28 | "roave/security-advisories": "dev-master", 29 | "phpstan/phpstan": "^0.12.94", 30 | "symfony/var-dumper": "^3.4 || ^4.0 || ^5.0", 31 | "symfony/phpunit-bridge": "^3.4 || ^4.0 || ^5.0", 32 | "symfony/property-info": "^3.4 || ^4.0 || ^5.0", 33 | "symfony/inflector": "^3.4 || ^4.0 || ^5.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /examples/expression_builder.php: -------------------------------------------------------------------------------- 1 | andX( 16 | $expr->orX( 17 | $expr->eq('A', 1), 18 | $expr->eq('B', 1) 19 | ), 20 | $expr->orX( 21 | $expr->eq('C', 1), 22 | $expr->eq('D', 1), 23 | $expr->eq('E', 1) 24 | ) 25 | ); 26 | 27 | dump($domain); 28 | dump($domain->toArray()); 29 | 30 | // Expected: [ '&', '|', (A), (B), '|', (C), '|', (D), (E) ] -------------------------------------------------------------------------------- /grumphp.yml: -------------------------------------------------------------------------------- 1 | parameters: 2 | git_dir: . 3 | bin_dir: vendor/bin 4 | tasks: 5 | composer: ~ 6 | # phpcsfixer2: 7 | # allow_risky: false 8 | # cache_file: ~ 9 | # config: ~ 10 | # rules: 11 | # '@Symfony': true 12 | # indentation_type: false 13 | # braces: 14 | # allow_single_line_closure: false 15 | # position_after_functions_and_oop_constructs: "next" 16 | # using_cache: true 17 | # verbose: true 18 | # diff: false 19 | # triggered_by: ['php'] 20 | phplint: ~ 21 | #phpunit: ~ 22 | yamllint: ~ 23 | ascii: 24 | failed: ~ 25 | succeeded: ~ -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | inferPrivatePropertyTypeFromConstructor: true 3 | checkGenericClassInNonGenericObjectType: false 4 | checkMissingIterableValueType: false 5 | paths: 6 | - %currentWorkingDirectory%/src 7 | ignoreErrors: 8 | #- '#(.*) expects class-string\\|T of object, (.*) given$#' -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | tests 18 | 19 | 20 | 21 | 22 | 23 | ./src 24 | ./vendor 25 | 26 | ./tests 27 | ./vendor 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/Client.php: -------------------------------------------------------------------------------- 1 | url = $url; 93 | $this->database = $database; 94 | $this->username = $username; 95 | $this->password = $password; 96 | $this->recordManager = new RecordManager($this); 97 | $this->logger = $logger; 98 | $this->initEndpoints(); 99 | } 100 | 101 | /** 102 | * Create a new client instance from array configuration. 103 | * The configuration array must have keys "url", "database", "username" and "password". 104 | * 105 | * @static 106 | * 107 | * @throws MissingConfigParameterException when a required parameter is missing 108 | */ 109 | public static function createFromConfig(array $config, LoggerInterface $logger = null): self 110 | { 111 | $getParam = static function ($config, $paramName, $paramKey) { 112 | $value = $config[$paramName] ?? $config[$paramKey] ?? null; 113 | 114 | if (null === $value) { 115 | throw new MissingConfigParameterException(sprintf('Missing config parameter name "%s" or parameter key %d', $paramName, $paramKey)); 116 | } 117 | 118 | return $value; 119 | }; 120 | 121 | $url = $getParam($config, 'url', 0); 122 | $database = $getParam($config, 'database', 1); 123 | $username = $getParam($config, 'username', 2); 124 | $password = $getParam($config, 'password', 3); 125 | 126 | return new self($url, $database, $username, $password, $logger); 127 | } 128 | 129 | /** 130 | * Create a new record. 131 | * 132 | * @throws InvalidArgumentException when $data is empty 133 | * @throws RequestException when request failed 134 | * 135 | * @return int the ID of the new record 136 | */ 137 | public function create(string $modelName, array $data): int 138 | { 139 | if (!$data) { 140 | throw new InvalidArgumentException('Data cannot be empty'); 141 | } 142 | 143 | return (int) $this->call($modelName, OrmQuery::CREATE, [$data]); 144 | } 145 | 146 | /** 147 | * Read records. 148 | * 149 | * @param array|int $ids 150 | * 151 | * @throws RequestException when request failed 152 | */ 153 | public function read(string $modelName, $ids, array $options = []): array 154 | { 155 | $ids = [is_int($ids) ? [$ids] : (array) $ids]; 156 | 157 | return (array) $this->call($modelName, OrmQuery::READ, $ids, $options); 158 | } 159 | 160 | /** 161 | * Update a record(s). 162 | * 163 | * @param array|int $ids 164 | * 165 | * @throws RequestException when request failed 166 | */ 167 | public function update(string $modelName, $ids, array $data = []): void 168 | { 169 | if (!$data) { 170 | return; 171 | } 172 | 173 | $this->call($modelName, OrmQuery::WRITE, [(array) $ids, $data]); 174 | } 175 | 176 | /** 177 | * Delete record(s). 178 | * 179 | * @param array|int $ids 180 | * 181 | * @throws RequestException when request failed 182 | */ 183 | public function delete(string $modelName, $ids): void 184 | { 185 | $ids = is_array($ids) ? $ids : [(int) $ids]; 186 | $this->call($modelName, OrmQuery::UNLINK, [$ids]); 187 | } 188 | 189 | /** 190 | * Search one ID of record by criteria and options. 191 | * 192 | * @throws InvalidArgumentException when $criteria value is not valid 193 | * @throws RequestException when request failed 194 | */ 195 | public function searchOne(string $modelName, iterable $criteria = null, array $options = []): ?int 196 | { 197 | $options['limit'] = 1; 198 | $result = $this->search($modelName, $criteria, $options); 199 | 200 | return array_shift($result); 201 | } 202 | 203 | /** 204 | * Search all ID of record(s) with options. 205 | * 206 | * @throws InvalidArgumentException when $criteria value is not valid 207 | * @throws RequestException when request failed 208 | * 209 | * @return array 210 | */ 211 | public function searchAll(string $modelName, array $options = []): array 212 | { 213 | $options['fields'] = ['id']; 214 | 215 | return array_column($this->findBy($modelName, null, $options), 'id'); 216 | } 217 | 218 | /** 219 | * Find ID of record(s) by criteria and options. 220 | * 221 | * @throws InvalidArgumentException when $criteria value is not valid 222 | * @throws RequestException when request failed 223 | * 224 | * @return array 225 | */ 226 | public function search(string $modelName, iterable $criteria = null, array $options = []): array 227 | { 228 | if (array_key_exists('fields', $options)) { 229 | unset($options['fields']); 230 | } 231 | 232 | return (array) $this->call($modelName, OrmQuery::SEARCH, $this->expr()->normalizeDomains($criteria), $options); 233 | } 234 | 235 | /** 236 | * Find ONE record by ID and options. 237 | * 238 | * @throws RequestException when request failed 239 | */ 240 | public function find(string $modelName, int $id, array $options = []): ?array 241 | { 242 | return $this->findOneBy($modelName, [ 243 | 'id' => $id, 244 | ], $options); 245 | } 246 | 247 | /** 248 | * Find ONE record by criteria and options. 249 | * 250 | * @throws InvalidArgumentException when $criteria value is not valid 251 | * @throws RequestException when request failed 252 | */ 253 | public function findOneBy(string $modelName, iterable $criteria = null, array $options = []): ?array 254 | { 255 | $result = $this->findBy($modelName, $criteria, $options); 256 | 257 | return array_pop($result); 258 | } 259 | 260 | /** 261 | * Find all record(s) with options. 262 | * 263 | * @throws RequestException when request failed 264 | * 265 | * @return array 266 | */ 267 | public function findAll(string $modelName, array $options = []): array 268 | { 269 | return $this->findBy($modelName, null, $options); 270 | } 271 | 272 | /** 273 | * Find record(s) by criteria and options. 274 | * 275 | * @throws InvalidArgumentException when $criteria value is not valid 276 | * @throws RequestException when request failed 277 | * 278 | * @return array 279 | */ 280 | public function findBy(string $modelName, iterable $criteria = null, array $options = []): array 281 | { 282 | return (array) $this->call($modelName, OrmQuery::SEARCH_READ, $this->expr()->normalizeDomains($criteria), $options); 283 | } 284 | 285 | /** 286 | * Check if a record exists. 287 | * 288 | * @throws RequestException when request failed 289 | */ 290 | public function exists(string $modelName, int $id): bool 291 | { 292 | return 1 === $this->count($modelName, [ 293 | 'id' => $id, 294 | ]); 295 | } 296 | 297 | /** 298 | * Count all records for a model. 299 | * 300 | * @throws InvalidArgumentException when $criteria value is not valid 301 | * @throws RequestException when request failed 302 | */ 303 | public function countAll(string $modelName): int 304 | { 305 | return $this->count($modelName); 306 | } 307 | 308 | /** 309 | * Count number of records for a model and criteria. 310 | * 311 | * @throws InvalidArgumentException when $criteria value is not valid 312 | * @throws RequestException when request failed 313 | */ 314 | public function count(string $modelName, iterable $criteria = null): int 315 | { 316 | return (int) $this->call($modelName, OrmQuery::SEARCH_COUNT, $this->expr()->normalizeDomains($criteria)); 317 | } 318 | 319 | /** 320 | * List model fields. 321 | * 322 | * @deprecated since version 7.0 and will be removed in 8.0, use the record manager schema instead. 323 | */ 324 | public function listFields(string $modelName, array $options = []): array 325 | { 326 | return (array) $this->call($modelName, self::LIST_FIELDS, [], $options); 327 | } 328 | 329 | /** 330 | * @return mixed 331 | */ 332 | public function call(string $name, string $method, array $parameters = [], array $options = []) 333 | { 334 | $loggerContext = [ 335 | 'request_id' => uniqid('rpc', true), 336 | 'name' => $name, 337 | 'method' => $method, 338 | 'parameters' => $parameters, 339 | 'options' => $options, 340 | ]; 341 | 342 | if ($this->logger) { 343 | $this->logger->debug('Calling method {name}::{method}', $loggerContext); 344 | } 345 | 346 | $result = $this->objectEndpoint->call('execute_kw', [ 347 | $this->database, 348 | $this->authenticate(), 349 | $this->password, 350 | $name, 351 | $method, 352 | $parameters, 353 | $options, 354 | ]); 355 | 356 | if ($this->logger) { 357 | $loggedResult = is_scalar($result) ? $result : json_encode($result); 358 | $this->logger->debug(sprintf('Request result: %s', $loggedResult), [ 359 | 'request_id' => $loggerContext['request_id'], 360 | ]); 361 | } 362 | 363 | return $result; 364 | } 365 | 366 | public function version(): array 367 | { 368 | return $this->commonEndpoint->call('version'); 369 | } 370 | 371 | /** 372 | * @throws AuthenticationException when authentication failed 373 | */ 374 | public function authenticate(): int 375 | { 376 | if (null === $this->uid) { 377 | $this->uid = $this->commonEndpoint->call('authenticate', [ 378 | $this->database, 379 | $this->username, 380 | $this->password, 381 | [], 382 | ]); 383 | 384 | if (!$this->uid || !is_int($this->uid)) { 385 | throw new AuthenticationException(); 386 | } 387 | } 388 | 389 | return $this->uid; 390 | } 391 | 392 | public function getIdentifier(): string 393 | { 394 | $database = preg_replace('([^a-zA-Z0-9_])', '_', $this->database); 395 | $user = preg_replace('([^a-zA-Z0-9_])', '_', $this->username); 396 | 397 | return sprintf('%s.%s.%s', sha1($this->url), $database, $user); 398 | } 399 | 400 | public function getUrl(): string 401 | { 402 | return $this->url; 403 | } 404 | 405 | public function setUrl(string $url): self 406 | { 407 | $this->url = $url; 408 | $this->initEndpoints(); 409 | 410 | return $this; 411 | } 412 | 413 | public function getDatabase(): string 414 | { 415 | return $this->database; 416 | } 417 | 418 | public function setDatabase(string $database): self 419 | { 420 | $this->database = $database; 421 | 422 | return $this; 423 | } 424 | 425 | public function getUsername(): string 426 | { 427 | return $this->username; 428 | } 429 | 430 | public function setUsername(string $username): self 431 | { 432 | $this->username = $username; 433 | 434 | return $this; 435 | } 436 | 437 | public function getPassword(): string 438 | { 439 | return $this->password; 440 | } 441 | 442 | public function setPassword(string $password): self 443 | { 444 | $this->password = $password; 445 | 446 | return $this; 447 | } 448 | 449 | public function getCommonEndpoint(): Endpoint 450 | { 451 | return $this->commonEndpoint; 452 | } 453 | 454 | public function getObjectEndpoint(): Endpoint 455 | { 456 | return $this->objectEndpoint; 457 | } 458 | 459 | public function getRecordManager(): RecordManager 460 | { 461 | return $this->recordManager; 462 | } 463 | 464 | public function getLogger(): ?LoggerInterface 465 | { 466 | return $this->logger; 467 | } 468 | 469 | public function setLogger(?LoggerInterface $logger): self 470 | { 471 | $this->logger = $logger; 472 | 473 | return $this; 474 | } 475 | 476 | /** 477 | * @internal 478 | */ 479 | private function initEndpoints(): void 480 | { 481 | $this->commonEndpoint = new Endpoint($this->url.'/'.self::ENDPOINT_COMMON); 482 | $this->objectEndpoint = new Endpoint($this->url.'/'.self::ENDPOINT_OBJECT); 483 | } 484 | } 485 | -------------------------------------------------------------------------------- /src/DBAL/Expression/CollectionOperation.php: -------------------------------------------------------------------------------- 1 | type = $type; 41 | $this->id = $id; 42 | $this->data = $data; 43 | } 44 | 45 | /** 46 | * @throws InvalidArgumentException when data is empty 47 | */ 48 | public static function create(array $data): self 49 | { 50 | if (!$data) { 51 | throw new InvalidArgumentException('Data cannot be empty'); 52 | } 53 | 54 | return new self(self::CREATE, 0, $data); 55 | } 56 | 57 | /** 58 | * @throws InvalidArgumentException when data is empty 59 | */ 60 | public static function update(int $id, array $data = []): self 61 | { 62 | if (!$data) { 63 | throw new InvalidArgumentException('Data cannot be empty'); 64 | } 65 | 66 | return new self(self::UPDATE, $id, $data); 67 | } 68 | 69 | public static function add(int $id): self 70 | { 71 | return new self(self::ADD, $id); 72 | } 73 | 74 | public static function remove(int $id): self 75 | { 76 | return new self(self::REMOVE, $id); 77 | } 78 | 79 | public static function delete(int $id): self 80 | { 81 | return new self(self::DELETE, $id); 82 | } 83 | 84 | /** 85 | * @param int[] $ids 86 | */ 87 | public static function replace(array $ids): self 88 | { 89 | return new self(self::REPLACE, 0, $ids); 90 | } 91 | 92 | public static function clear(): self 93 | { 94 | return new self(self::CLEAR); 95 | } 96 | 97 | public function getType(): int 98 | { 99 | return $this->type; 100 | } 101 | 102 | public function setType(int $type): self 103 | { 104 | $this->type = $type; 105 | 106 | return $this; 107 | } 108 | 109 | public function getId(): int 110 | { 111 | return $this->id; 112 | } 113 | 114 | public function setId(int $id): self 115 | { 116 | $this->id = $id; 117 | 118 | return $this; 119 | } 120 | 121 | /** 122 | * @return array|int 123 | */ 124 | public function getData() 125 | { 126 | return $this->data; 127 | } 128 | 129 | /** 130 | * @param array|int $data 131 | */ 132 | public function setData($data): self 133 | { 134 | $this->data = $data; 135 | 136 | return $this; 137 | } 138 | 139 | public function toArray(): array 140 | { 141 | return [$this->type, $this->id, $this->data]; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/DBAL/Expression/Comparison.php: -------------------------------------------------------------------------------- 1 | '; 18 | public const GREATER_THAN_OR_EQUAL = '>='; 19 | public const EQUAL_LIKE = '=like'; 20 | public const INSENSITIVE_EQUAL_LIKE = '=ilike'; 21 | public const LIKE = 'like'; 22 | public const NOT_LIKE = 'not like'; 23 | public const INSENSITIVE_LIKE = 'ilike'; 24 | public const INSENSITIVE_NOT_LIKE = 'not ilike'; 25 | public const IN = 'in'; 26 | public const NOT_IN = 'not in'; 27 | 28 | /** 29 | * @var string[] 30 | */ 31 | private static $operators = [ 32 | self::UNSET_OR_EQUAL_TO, 33 | self::EQUAL_TO, 34 | self::NOT_EQUAL_TO, 35 | self::LESS_THAN, 36 | self::LESS_THAN_OR_EQUAL, 37 | self::GREATER_THAN, 38 | self::GREATER_THAN_OR_EQUAL, 39 | self::EQUAL_LIKE, 40 | self::INSENSITIVE_EQUAL_LIKE, 41 | self::LIKE, 42 | self::NOT_LIKE, 43 | self::INSENSITIVE_LIKE, 44 | self::INSENSITIVE_NOT_LIKE, 45 | self::IN, 46 | self::NOT_IN, 47 | ]; 48 | 49 | /** 50 | * @var string 51 | */ 52 | private $fieldName; 53 | 54 | /** 55 | * @var string 56 | */ 57 | private $operator; 58 | 59 | /** 60 | * @var mixed 61 | */ 62 | private $value; 63 | 64 | /** 65 | * @param mixed $value 66 | */ 67 | public function __construct(string $fieldName, string $operator, $value) 68 | { 69 | $this->fieldName = $fieldName; 70 | $this->operator = $operator; 71 | $this->value = $value; 72 | } 73 | 74 | public function __clone() 75 | { 76 | $this->value = is_object($this->value) ? clone $this->value : $this->value; 77 | } 78 | 79 | public function getIterator(): ArrayIterator 80 | { 81 | return new ArrayIterator($this->toArray()); 82 | } 83 | 84 | public function toArray(): array 85 | { 86 | return [$this->fieldName, $this->operator, $this->value]; 87 | } 88 | 89 | /** 90 | * @return string[] 91 | */ 92 | public static function getOperators(): array 93 | { 94 | return self::$operators; 95 | } 96 | 97 | public function getFieldName(): string 98 | { 99 | return $this->fieldName; 100 | } 101 | 102 | public function setFieldName(string $fieldName): self 103 | { 104 | $this->fieldName = $fieldName; 105 | 106 | return $this; 107 | } 108 | 109 | public function getOperator(): string 110 | { 111 | return $this->operator; 112 | } 113 | 114 | public function setOperator(string $operator): self 115 | { 116 | $this->operator = $operator; 117 | 118 | return $this; 119 | } 120 | 121 | /** 122 | * @return mixed 123 | */ 124 | public function getValue() 125 | { 126 | return $this->value; 127 | } 128 | 129 | /** 130 | * @param mixed $value 131 | */ 132 | public function setValue($value): self 133 | { 134 | $this->value = $value; 135 | 136 | return $this; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/DBAL/Expression/CompositeDomain.php: -------------------------------------------------------------------------------- 1 | operator = $operator; 38 | $this->setDomains($domains); 39 | } 40 | 41 | public function __clone() 42 | { 43 | foreach ($this->domains as $key => $domain) { 44 | $this->domains[$key] = clone $domain; 45 | } 46 | } 47 | 48 | /** 49 | * {@inheritdoc} 50 | * 51 | * @return DomainInterface[]|Generator 52 | */ 53 | public function getIterator(): Generator 54 | { 55 | foreach ($this->getDomains() as $key => $domain) { 56 | yield $key => $domain; 57 | } 58 | } 59 | 60 | /** 61 | * @static 62 | * 63 | * @return string[] 64 | */ 65 | public static function getOperators(): array 66 | { 67 | return self::$operators; 68 | } 69 | 70 | public function toArray(): array 71 | { 72 | $domain = $this->prepare(); 73 | 74 | if (!($domain instanceof self)) { 75 | return $domain ? $domain->toArray() : []; 76 | } 77 | 78 | $result = [$domain->getOperator()]; 79 | 80 | foreach ($domain->getDomains() as $domain) { 81 | $domainArray = $domain->toArray(); 82 | 83 | if ($domain instanceof self) { 84 | foreach ($domainArray as $value) { 85 | $result[] = $value; 86 | } 87 | 88 | continue; 89 | } 90 | 91 | $result[] = $domainArray; 92 | } 93 | 94 | return $result; 95 | } 96 | 97 | /** 98 | * @internal 99 | * 100 | * Create a copy according to arity policy of Odoo polish notation 101 | */ 102 | private function prepare(): ?DomainInterface 103 | { 104 | $domains = $this->domains; 105 | $nbDomains = count($domains); 106 | 107 | if (0 === $nbDomains) { 108 | return null; 109 | } 110 | 111 | if (1 === $nbDomains) { 112 | return self::NOT === $this->operator ? $this : array_shift($domains); 113 | } 114 | 115 | if (self::NOT === $this->operator) { 116 | $andX = new self(self::AND, $domains); 117 | 118 | return new self($this->operator, [$andX->prepare()]); 119 | } 120 | 121 | if (2 === $nbDomains) { 122 | return $this; 123 | } 124 | 125 | foreach ($domains as $key => $subDomain) { 126 | if ($subDomain instanceof self) { 127 | $domains[$key] = $subDomain->prepare(); 128 | 129 | if (!$domains[$key]) { 130 | unset($domains[$key]); 131 | } 132 | 133 | continue; 134 | } 135 | } 136 | 137 | $firstDomain = array_shift($domains); 138 | $subDomain = new self($this->operator, $domains); 139 | 140 | return new self($this->operator, [ 141 | $firstDomain, $subDomain->prepare(), 142 | ]); 143 | } 144 | 145 | public function getOperator(): string 146 | { 147 | return $this->operator; 148 | } 149 | 150 | public function setOperator(string $operator): self 151 | { 152 | $this->operator = $operator; 153 | 154 | return $this; 155 | } 156 | 157 | /** 158 | * @return DomainInterface[] 159 | */ 160 | public function getDomains(): array 161 | { 162 | return array_values($this->domains); 163 | } 164 | 165 | public function setDomains(array $domains = []): self 166 | { 167 | $this->domains = []; 168 | 169 | foreach ($domains as $domain) { 170 | if (!$domain) { 171 | continue; 172 | } 173 | 174 | $this->add($domain); 175 | } 176 | 177 | return $this; 178 | } 179 | 180 | public function add(DomainInterface $domain): self 181 | { 182 | if (!$this->has($domain)) { 183 | $this->domains[] = $domain; 184 | } 185 | 186 | return $this; 187 | } 188 | 189 | public function remove(DomainInterface $domain): self 190 | { 191 | foreach ($this->domains as $key => $value) { 192 | if ($value === $domain) { 193 | unset($this->domains[$key]); 194 | } 195 | } 196 | 197 | return $this; 198 | } 199 | 200 | public function has(DomainInterface $domain): bool 201 | { 202 | return in_array($domain, $this->domains, true); 203 | } 204 | 205 | public function count(): int 206 | { 207 | return count($this->domains); 208 | } 209 | 210 | public function isEmpty(): bool 211 | { 212 | return 0 === count($this->domains); 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/DBAL/Expression/ConversionException.php: -------------------------------------------------------------------------------- 1 | data = $data; 17 | } 18 | 19 | public function getIterator(): ArrayIterator 20 | { 21 | return new ArrayIterator($this->toArray()); 22 | } 23 | 24 | public function toArray(): array 25 | { 26 | return $this->data; 27 | } 28 | 29 | public function getData(): array 30 | { 31 | return $this->data; 32 | } 33 | 34 | public function setData(array $data): self 35 | { 36 | $this->data = $data; 37 | 38 | return $this; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/DBAL/Expression/DomainInterface.php: -------------------------------------------------------------------------------- 1 | getValues($values)); 147 | } 148 | 149 | /** 150 | * Check if the field is NOT IN values list. 151 | * 152 | * @param bool|int|float|string|array $values 153 | */ 154 | public function notIn(string $fieldName, $values): Comparison 155 | { 156 | return new Comparison($fieldName, Comparison::NOT_IN, $this->getValues($values)); 157 | } 158 | 159 | /** 160 | * @internal 161 | * 162 | * @param bool|int|float|string|array $values 163 | */ 164 | private function getValues($values): array 165 | { 166 | return is_array($values) ? $values : [$values]; 167 | } 168 | 169 | /** 170 | * Adds a new record created from data. 171 | * 172 | * @throws InvalidArgumentException when $data is empty 173 | */ 174 | public function createRecord(array $data): CollectionOperation 175 | { 176 | return CollectionOperation::create($data); 177 | } 178 | 179 | /** 180 | * Updates an existing record of id $id with data. 181 | * /!\ Can not be used in record create operation. 182 | * 183 | * @throws InvalidArgumentException when $data is empty 184 | */ 185 | public function updateRecord(int $id, array $data): CollectionOperation 186 | { 187 | if (!$data) { 188 | throw new InvalidArgumentException('Data cannot be empty'); 189 | } 190 | 191 | return CollectionOperation::update($id, $data); 192 | } 193 | 194 | /** 195 | * Adds an existing record of id $id to the collection. 196 | */ 197 | public function addRecord(int $id): CollectionOperation 198 | { 199 | return CollectionOperation::add($id); 200 | } 201 | 202 | /** 203 | * Removes the record of id $id from the collection, but does not delete it. 204 | * /!\ Can not be used in record create operation. 205 | */ 206 | public function removeRecord(int $id): CollectionOperation 207 | { 208 | return CollectionOperation::remove($id); 209 | } 210 | 211 | /** 212 | * Removes the record of id $id from the collection, then deletes it from the database. 213 | * /!\ Can not be used in record create operation. 214 | */ 215 | public function deleteRecord(int $id): CollectionOperation 216 | { 217 | return CollectionOperation::delete($id); 218 | } 219 | 220 | /** 221 | * Replaces all existing records in the collection by the $ids list, 222 | * Equivalent to using the command "clear" followed by a command "add" for each id in $ids. 223 | */ 224 | public function replaceRecords(array $ids = []): CollectionOperation 225 | { 226 | return CollectionOperation::replace($ids); 227 | } 228 | 229 | /** 230 | * Removes all records from the collection, equivalent to using the command "remove" on every record explicitly. 231 | * /!\ Can not be used in record create operation. 232 | */ 233 | public function clearRecords(): CollectionOperation 234 | { 235 | return CollectionOperation::clear(); 236 | } 237 | 238 | /** 239 | * @throws InvalidArgumentException when $criteria value is not valid 240 | * @throws ConversionException on data conversion failure 241 | */ 242 | public function normalizeDomains(iterable $criteria = null): array 243 | { 244 | if (!$criteria) { 245 | return [[]]; 246 | } 247 | 248 | if (is_array($criteria)) { 249 | $normalizedCriteria = $this->andX(); 250 | 251 | foreach ($criteria as $fieldName => $value) { 252 | $normalizedCriteria->add($this->eq($fieldName, $this->formatValue($value))); 253 | } 254 | 255 | $criteria = $normalizedCriteria; 256 | } 257 | 258 | if (!$criteria instanceof DomainInterface) { 259 | throw new InvalidArgumentException(sprintf('Expected parameter #1 of type %s|array<%s|array>, %s given', DomainInterface::class, DomainInterface::class, gettype($criteria))); 260 | } 261 | 262 | return $criteria instanceof CompositeDomain ? [$this->formatValue($criteria->toArray())] : [[$this->formatValue($criteria->toArray())]]; 263 | } 264 | 265 | /** 266 | * @throws ConversionException on data conversion failure 267 | */ 268 | public function normalizeData(array $data = []): array 269 | { 270 | return $this->formatValue($data); 271 | } 272 | 273 | /** 274 | * @param mixed $value 275 | * 276 | * @throws ConversionException on data conversion failure 277 | * 278 | * @return mixed 279 | */ 280 | private function formatValue($value) 281 | { 282 | if (is_scalar($value)) { 283 | return $value; 284 | } 285 | 286 | if (is_array($value) || is_iterable($value)) { 287 | $values = []; 288 | 289 | foreach ($value as $key => $aValue) { 290 | $values[$key] = $this->formatValue($aValue); 291 | } 292 | 293 | return $values; 294 | } 295 | 296 | if (is_object($value)) { 297 | if ($value instanceof DomainInterface) { 298 | return $this->formatValue($value->toArray()); 299 | } 300 | 301 | if ($value instanceof CollectionOperation) { 302 | return $this->formatValue($value->toArray()); 303 | } 304 | 305 | if ($value instanceof DateTimeInterface) { 306 | try { 307 | $date = new DateTime(sprintf('@%s', $value->getTimestamp())); 308 | } catch (Exception $e) { 309 | throw new ConversionException(sprintf('Failed to convert date from timestamp "%d"', $value->getTimestamp()), 0, $e); 310 | } 311 | 312 | $date->setTimezone(new \DateTimeZone('UTC')); 313 | 314 | return $date->format('Y-m-d H:i:s'); 315 | } 316 | } 317 | 318 | try { 319 | return (string) $value; 320 | } catch (Exception $e) { 321 | throw new ConversionException(sprintf('Failed to convert value of type "%s" to string.', gettype($value)), 0, $e); 322 | } 323 | } 324 | } 325 | -------------------------------------------------------------------------------- /src/DBAL/Expression/ExpressionBuilderAwareTrait.php: -------------------------------------------------------------------------------- 1 | getExpressionBuilder(); 15 | } 16 | 17 | public function getExpressionBuilder(): ExpressionBuilder 18 | { 19 | if (!$this->expressionBuilder) { 20 | $this->expressionBuilder = new ExpressionBuilder(); 21 | } 22 | 23 | return $this->expressionBuilder; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/DBAL/Query/AbstractQuery.php: -------------------------------------------------------------------------------- 1 | recordManager = $recordManager; 37 | $this->name = $name; 38 | $this->method = $method; 39 | } 40 | 41 | public function getName(): string 42 | { 43 | return $this->name; 44 | } 45 | 46 | /** 47 | * @return $this 48 | */ 49 | public function setName(string $name) 50 | { 51 | $this->name = $name; 52 | 53 | return $this; 54 | } 55 | 56 | public function getMethod(): string 57 | { 58 | return $this->method; 59 | } 60 | 61 | /** 62 | * @return $this 63 | */ 64 | public function setMethod(string $method) 65 | { 66 | $this->method = $method; 67 | 68 | return $this; 69 | } 70 | 71 | public function getParameters(): array 72 | { 73 | return $this->parameters; 74 | } 75 | 76 | /** 77 | * @return $this 78 | */ 79 | public function setParameters(array $parameters = []) 80 | { 81 | $this->parameters = $parameters; 82 | 83 | return $this; 84 | } 85 | 86 | public function getOptions(): array 87 | { 88 | return $this->options; 89 | } 90 | 91 | /** 92 | * @return $this 93 | */ 94 | public function setOptions(array $options = []) 95 | { 96 | $this->options = $options; 97 | 98 | return $this; 99 | } 100 | 101 | /** 102 | * Add an option on the query. 103 | * 104 | * @param mixed $value 105 | * 106 | * @return $this 107 | */ 108 | public function addOption(string $name, $value) 109 | { 110 | $this->options[$name] = $value; 111 | 112 | return $this; 113 | } 114 | 115 | /** 116 | * Execute the query. 117 | * Allowed methods: all. 118 | * 119 | * @return mixed 120 | */ 121 | public function execute() 122 | { 123 | return $this->recordManager->executeQuery($this); 124 | } 125 | 126 | /** 127 | * Gets the related manager of the query. 128 | */ 129 | public function getRecordManager(): RecordManager 130 | { 131 | return $this->recordManager; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/DBAL/Query/NativeQuery.php: -------------------------------------------------------------------------------- 1 | method = $method; 43 | 44 | return $this; 45 | } 46 | 47 | /** 48 | * Counts the number of records from parameters. 49 | * Allowed methods: SEARCH, SEARCH_READ. 50 | * 51 | * @throws QueryException on invalid query method 52 | */ 53 | public function count(): int 54 | { 55 | if (!in_array($this->method, [self::SEARCH, self::SEARCH_READ])) { 56 | throw new QueryException(sprintf('You can count results with method "%s" and "%s" only.', self::SEARCH, self::SEARCH_READ)); 57 | } 58 | 59 | $query = new self($this->recordManager, $this->name, self::SEARCH_COUNT); 60 | $query->setParameters($this->parameters); 61 | 62 | return (int) $query->execute(); 63 | } 64 | 65 | /** 66 | * Gets just ONE scalar result. 67 | * Allowed methods: SEARCH, SEARCH_READ. 68 | * 69 | * @return bool|int|float|string 70 | * 71 | * @throws NoUniqueResultException on no unique result 72 | * @throws NoResultException on no result 73 | * @throws QueryException on invalid query method 74 | */ 75 | public function getSingleScalarResult() 76 | { 77 | $result = $this->getOneOrNullScalarResult(); 78 | 79 | if (!$result) { 80 | throw new NoResultException(); 81 | } 82 | 83 | return $result; 84 | } 85 | 86 | /** 87 | * Gets one or NULL scalar result. 88 | * Allowed methods: SEARCH, SEARCH_READ. 89 | * 90 | * @return bool|int|float|string|null 91 | * 92 | * @throws NoUniqueResultException on no unique result 93 | * @throws QueryException on invalid query method 94 | */ 95 | public function getOneOrNullScalarResult() 96 | { 97 | $result = $this->getScalarResult(); 98 | 99 | if (count($result) > 1) { 100 | throw new NoUniqueResultException(); 101 | } 102 | 103 | return array_shift($result); 104 | } 105 | 106 | /** 107 | * Gets a list of scalar result. 108 | * Allowed methods: SEARCH, SEARCH_READ. 109 | * 110 | * @throws QueryException on invalid query method 111 | * 112 | * @return array 113 | */ 114 | public function getScalarResult(): array 115 | { 116 | $result = $this->getResult(); 117 | 118 | if (self::SEARCH === $this->method) { 119 | return $result; 120 | } 121 | 122 | $selectedFields = $this->options['fields'] ?? []; 123 | if (count($selectedFields) > 1) { 124 | throw new QueryException('More than one field selected.'); 125 | } 126 | 127 | $selectedFieldName = $selectedFields[0] ?? 'id'; 128 | 129 | foreach ($result as $key => $value) { 130 | $result[$key] = $value[$selectedFieldName] ?? null; 131 | } 132 | 133 | return $result; 134 | } 135 | 136 | /** 137 | * Gets one row. 138 | * Allowed methods: SEARCH, SEARCH_READ. 139 | * 140 | * @throws NoUniqueResultException on no unique result 141 | * @throws NoResultException on no result 142 | * @throws QueryException on invalid query method 143 | */ 144 | public function getSingleResult(): array 145 | { 146 | $result = $this->getOneOrNullResult(); 147 | $result = array_shift($result); 148 | 149 | if (!$result) { 150 | throw new NoResultException(); 151 | } 152 | 153 | return $result; 154 | } 155 | 156 | /** 157 | * Gets one or NULL row. 158 | * Allowed methods: SEARCH, SEARCH_READ. 159 | * 160 | * @throws NoUniqueResultException on no unique result 161 | * @throws QueryException on invalid query method 162 | */ 163 | public function getOneOrNullResult(): ?array 164 | { 165 | $result = $this->getResult(); 166 | 167 | if (count($result) > 1) { 168 | throw new NoUniqueResultException(); 169 | } 170 | 171 | return array_shift($result); 172 | } 173 | 174 | /** 175 | * Gets all result rows. 176 | * Allowed methods: SEARCH, SEARCH_READ. 177 | * 178 | * @throws QueryException on invalid query method 179 | */ 180 | public function getResult(): array 181 | { 182 | if (!in_array($this->method, [self::SEARCH, self::SEARCH_READ])) { 183 | throw new QueryException(sprintf('You can get results with methods "%s" and "%s" only.', self::SEARCH, self::SEARCH_READ)); 184 | } 185 | 186 | return (array) $this->execute(); 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/DBAL/Query/QueryBuilder.php: -------------------------------------------------------------------------------- 1 | recordManager = $recordManager; 77 | $this->from($modelName); 78 | } 79 | 80 | /** 81 | * Defines the query of type "SELECT" with selected fields. 82 | * No fields selected = all fields returned. 83 | * 84 | * @param array|string|null $fields 85 | */ 86 | public function select($fields = null): self 87 | { 88 | $this->type = self::SELECT; 89 | $this->select = []; 90 | $this->values = []; 91 | $this->ids = []; 92 | 93 | $fields = $fields ? (array) $fields : []; 94 | 95 | foreach ($fields as $fieldName) { 96 | $this->addSelect($fieldName); 97 | } 98 | 99 | return $this; 100 | } 101 | 102 | /** 103 | * Defines the query of type "SEARCH". 104 | */ 105 | public function search(): self 106 | { 107 | $this->type = self::SEARCH; 108 | $this->select = []; 109 | $this->values = []; 110 | $this->ids = []; 111 | 112 | return $this; 113 | } 114 | 115 | /** 116 | * Defines the query of type "INSERT". 117 | */ 118 | public function insert(): self 119 | { 120 | $this->type = self::INSERT; 121 | $this->select = []; 122 | $this->ids = []; 123 | $this->where = null; 124 | 125 | return $this; 126 | } 127 | 128 | /** 129 | * Defines the query of type "UPDATE" with ids of records to update. 130 | * 131 | * @param int[]|int $ids 132 | */ 133 | public function update($ids): self 134 | { 135 | $this->type = self::UPDATE; 136 | $this->select = []; 137 | 138 | return $this->setIds(is_array($ids) ? $ids : [(int) $ids]); 139 | } 140 | 141 | /** 142 | * Defines the query of type "DELETE" with ids of records to delete. 143 | * 144 | * @param int[]|int $ids 145 | */ 146 | public function delete($ids): self 147 | { 148 | $this->type = self::DELETE; 149 | $this->select = []; 150 | $this->values = []; 151 | 152 | return $this->setIds(is_array($ids) ? $ids : [(int) $ids]); 153 | } 154 | 155 | /** 156 | * Adds a field to select. 157 | * 158 | * @throws QueryException when the type of the query is not "SELECT" 159 | */ 160 | public function addSelect(string $fieldName): self 161 | { 162 | if (self::SELECT !== $this->type) { 163 | throw new QueryException('You can select fields in query of type "SELECT" only.'); 164 | } 165 | 166 | if (!in_array($fieldName, $this->select)) { 167 | $this->select[] = $fieldName; 168 | } 169 | 170 | return $this; 171 | } 172 | 173 | /** 174 | * Gets selected fields. 175 | */ 176 | public function getSelect(): array 177 | { 178 | return $this->select; 179 | } 180 | 181 | /** 182 | * Sets the target model name. 183 | */ 184 | public function from(string $modelName): self 185 | { 186 | $this->from = $modelName; 187 | 188 | return $this; 189 | } 190 | 191 | /** 192 | * Gets the target model name of the query. 193 | */ 194 | public function getFrom(): ?string 195 | { 196 | return $this->from; 197 | } 198 | 199 | /** 200 | * Sets target IDs in case of query of type "UPDATE" or "DELETE". 201 | * 202 | * @throws QueryException when the type of the query is not "UPDATE" nor "DELETE" 203 | */ 204 | public function setIds(array $ids): self 205 | { 206 | $this->ids = []; 207 | 208 | foreach ($ids as $id) { 209 | $this->addId($id); 210 | } 211 | 212 | return $this; 213 | } 214 | 215 | /** 216 | * Adds target ID in case of query of type "UPDATE" or "DELETE". 217 | * 218 | * @throws QueryException when the type of the query is not "UPDATE" nor "DELETE" 219 | */ 220 | public function addId(int $id): self 221 | { 222 | if (!in_array($this->type, [self::UPDATE, self::DELETE])) { 223 | throw new QueryException('You can set indexes in query of type "UPDATE" or "DELETE" only.'); 224 | } 225 | 226 | if (!in_array($id, $this->ids, true)) { 227 | $this->ids[] = $id; 228 | } 229 | 230 | return $this; 231 | } 232 | 233 | /** 234 | * @return int[] 235 | */ 236 | public function getIds(): array 237 | { 238 | return $this->ids; 239 | } 240 | 241 | /** 242 | * Sets field values in case of query of type "INSERT" or "UPDATE". 243 | * 244 | * @throws QueryException when the type of the query is not "INSERT" nor "UPDATE" 245 | */ 246 | public function setValues(array $values = []): self 247 | { 248 | $this->values = []; 249 | 250 | foreach ($values as $fieldName => $value) { 251 | $this->set($fieldName, $value); 252 | } 253 | 254 | return $this; 255 | } 256 | 257 | /** 258 | * Set a field value in case of query of type "INSERT" or "UPDATE". 259 | * 260 | * @param mixed $value 261 | * 262 | * @throws QueryException when the type of the query is not "INSERT" nor "UPDATE" 263 | */ 264 | public function set(string $fieldName, $value): self 265 | { 266 | if (!in_array($this->type, [self::INSERT, self::UPDATE])) { 267 | throw new QueryException('You can set values in query of type "INSERT" or "UPDATE" only.'); 268 | } 269 | 270 | $this->values[$fieldName] = $value; 271 | 272 | return $this; 273 | } 274 | 275 | /** 276 | * Gets field values set in case of query of type "INSERT" or "UPDATE". 277 | */ 278 | public function getValues(): array 279 | { 280 | return $this->values; 281 | } 282 | 283 | /** 284 | * Sets criteria for queries of type "SELECT" and "SEARCH". 285 | * 286 | * @throws QueryException when the type of the query is not "SELECT" not "SEARCH" 287 | */ 288 | public function where(?DomainInterface $domain = null): self 289 | { 290 | $this->assertSupportsWhereClause(); 291 | $this->where = $domain; 292 | 293 | return $this; 294 | } 295 | 296 | /** 297 | * Takes the WHERE clause and adds a node with logical operator AND. 298 | * 299 | * @throws QueryException when the type of the query is not "SELECT" nor "SEARCH" 300 | */ 301 | public function andWhere(DomainInterface $domain): self 302 | { 303 | $this->assertSupportsWhereClause(); 304 | $this->where = $this->expr()->andX($this->where, $domain); 305 | 306 | return $this; 307 | } 308 | 309 | /** 310 | * Takes the WHERE clause and adds a node with logical operator OR. 311 | * 312 | * @throws QueryException when the type of the query is not "SELECT" nor "SEARCH" 313 | */ 314 | public function orWhere(DomainInterface $domain): self 315 | { 316 | $this->assertSupportsWhereClause(); 317 | $this->where = $this->expr()->orX($this->where, $domain); 318 | 319 | return $this; 320 | } 321 | 322 | /** 323 | * Gets the WHERE clause. 324 | */ 325 | public function getWhere(): ?DomainInterface 326 | { 327 | return $this->where; 328 | } 329 | 330 | /** 331 | * @internal 332 | * 333 | * @throws QueryException when the type of the query is not "SELECT" nor "SEARCH" 334 | */ 335 | private function assertSupportsWhereClause(): void 336 | { 337 | if (!in_array($this->type, [self::SELECT, self::SEARCH])) { 338 | throw new QueryException('You can set criteria in query of type "SELECT" or "SEARCH" only.'); 339 | } 340 | } 341 | 342 | /** 343 | * Sets orders. 344 | */ 345 | public function setOrders(array $orders = []): self 346 | { 347 | $this->orders = []; 348 | 349 | foreach ($orders as $fieldName => $isAsc) { 350 | $this->addOrderBy($fieldName, $isAsc); 351 | } 352 | 353 | return $this; 354 | } 355 | 356 | /** 357 | * Clears orders and adds one. 358 | */ 359 | public function orderBy(string $fieldName, bool $isAsc = true): self 360 | { 361 | $this->orders = []; 362 | 363 | return $this->addOrderBy($fieldName, $isAsc); 364 | } 365 | 366 | /** 367 | * Adds order. 368 | * 369 | * @throws QueryException when the query type is not valid 370 | */ 371 | public function addOrderBy(string $fieldName, bool $isAsc = true): self 372 | { 373 | if (!in_array($this->type, [self::SELECT, self::SEARCH])) { 374 | throw new QueryException('You can set orders in query of type "SELECT", "SEARCH" only.'); 375 | } 376 | 377 | $this->orders[$fieldName] = $isAsc; 378 | 379 | return $this; 380 | } 381 | 382 | /** 383 | * Gets ordered fields. 384 | */ 385 | public function getOrders(): array 386 | { 387 | return $this->orders; 388 | } 389 | 390 | /** 391 | * Sets the max results of the query (limit). 392 | */ 393 | public function setMaxResults(?int $maxResults): self 394 | { 395 | $this->maxResults = $maxResults; 396 | 397 | return $this; 398 | } 399 | 400 | /** 401 | * Gets the max results of the query. 402 | */ 403 | public function getMaxResults(): ?int 404 | { 405 | return $this->maxResults; 406 | } 407 | 408 | /** 409 | * Sets the first results of the query (offset). 410 | */ 411 | public function setFirstResult(?int $firstResult): self 412 | { 413 | $this->firstResult = $firstResult; 414 | 415 | return $this; 416 | } 417 | 418 | /** 419 | * Gets the first results of the query. 420 | */ 421 | public function getFirstResult(): ?int 422 | { 423 | return $this->firstResult; 424 | } 425 | 426 | /** 427 | * Computes and returns the query. 428 | * 429 | * @throws QueryException on invalid query 430 | * @throws ConversionException on data conversion failure 431 | */ 432 | public function getQuery(): OrmQuery 433 | { 434 | $from = $this->from; 435 | if (null === $from) { 436 | throw new QueryException('Missing FROM clause (model name).'); 437 | } 438 | 439 | switch ($this->type) { 440 | case self::SELECT: 441 | $method = OrmQuery::SEARCH_READ; 442 | break; 443 | case self::SEARCH: 444 | $method = OrmQuery::SEARCH; 445 | break; 446 | case self::INSERT: 447 | $method = OrmQuery::CREATE; 448 | break; 449 | case self::UPDATE: 450 | $method = OrmQuery::WRITE; 451 | break; 452 | case self::DELETE: 453 | $method = OrmQuery::UNLINK; 454 | break; 455 | default: 456 | throw new InvalidArgumentException(sprintf('The query type "%s" is not valid.', $this->type)); 457 | } 458 | 459 | $query = new OrmQuery($this->recordManager, $from, $method); 460 | 461 | switch ($this->type) { 462 | case self::SEARCH: 463 | case self::SELECT: 464 | $parameters = $this->expr()->normalizeDomains($this->where); 465 | break; 466 | case self::DELETE: 467 | if (!$this->ids) { 468 | throw new QueryException('You must set indexes for queries of type "DELETE".'); 469 | } 470 | 471 | $parameters = [$this->ids]; 472 | break; 473 | default: 474 | if (!$this->values) { 475 | throw new QueryException('You must set values for queries of type "INSERT" or "UPDATE".'); 476 | } 477 | 478 | $parameters = $this->expr()->normalizeData($this->values); 479 | 480 | if (self::UPDATE === $this->type) { 481 | if (!$this->ids) { 482 | throw new QueryException('You must set indexes for queries of type "UPDATE".'); 483 | } 484 | 485 | $parameters = [$this->ids, $parameters]; 486 | } 487 | break; 488 | } 489 | 490 | $query->setParameters($parameters); 491 | 492 | if (in_array($this->type, [self::SELECT, self::SEARCH])) { 493 | $options = []; 494 | 495 | if (self::SELECT === $this->type && $this->select) { 496 | $options['fields'] = $this->select; 497 | } 498 | 499 | $orders = $this->orders; 500 | 501 | if ($orders) { 502 | foreach ($orders as $fieldName => $isAsc) { 503 | $orders[$fieldName] = sprintf('%s %s', $fieldName, $isAsc ? 'asc' : 'desc'); 504 | } 505 | 506 | $options['order'] = implode(', ', $orders); 507 | } 508 | 509 | if ($this->firstResult) { 510 | $options['offset'] = $this->firstResult; 511 | } 512 | 513 | if ($this->maxResults) { 514 | $options['limit'] = $this->maxResults; 515 | } 516 | 517 | $query->setOptions($options); 518 | } 519 | 520 | return $query; 521 | } 522 | 523 | /** 524 | * Gets the type of the query. 525 | */ 526 | public function getType(): string 527 | { 528 | return $this->type; 529 | } 530 | 531 | /** 532 | * Gets the related manager of the query. 533 | */ 534 | public function getRecordManager(): RecordManager 535 | { 536 | return $this->recordManager; 537 | } 538 | 539 | /** 540 | * Shortcut to the expression builder of the related client. 541 | */ 542 | public function expr(): ExpressionBuilder 543 | { 544 | return $this->recordManager->getExpressionBuilder(); 545 | } 546 | } 547 | -------------------------------------------------------------------------------- /src/DBAL/Query/QueryException.php: -------------------------------------------------------------------------------- 1 | client = $client; 38 | $this->schema = new Schema($client); 39 | } 40 | 41 | /** 42 | * Create a new record. 43 | * 44 | * @return int the ID of the new record 45 | */ 46 | public function create(string $modelName, array $data): int 47 | { 48 | return $this 49 | ->getRepository($modelName) 50 | ->insert($data); 51 | } 52 | 53 | /** 54 | * Update record(s). 55 | * 56 | * NB: It is not currently possible to perform “computed” updates (by criteria). 57 | * To do it, you have to perform a search then an update with search result IDs. 58 | * 59 | * @param array|int $ids 60 | */ 61 | public function update(string $modelName, $ids, array $data = []): void 62 | { 63 | $this 64 | ->getRepository($modelName) 65 | ->update($ids, $data); 66 | } 67 | 68 | /** 69 | * Delete record(s). 70 | * 71 | * NB: It is not currently possible to perform “computed” deletes (by criteria). 72 | * To do it, you have to perform a search then a delete with search result IDs. 73 | * 74 | * @param array|int $ids 75 | */ 76 | public function delete(string $modelName, $ids): void 77 | { 78 | $this 79 | ->getRepository($modelName) 80 | ->delete($ids); 81 | } 82 | 83 | /** 84 | * Search one ID of record by criteria. 85 | */ 86 | public function searchOne(string $modelName, ?DomainInterface $criteria): ?int 87 | { 88 | return $this 89 | ->getRepository($modelName) 90 | ->searchOne($criteria); 91 | } 92 | 93 | /** 94 | * Search all ID of record(s). 95 | * 96 | * @return int[] 97 | */ 98 | public function searchAll(string $modelName, array $orders = [], int $limit = null, int $offset = null): array 99 | { 100 | return $this 101 | ->getRepository($modelName) 102 | ->searchAll($orders, $limit, $offset); 103 | } 104 | 105 | /** 106 | * Search ID of record(s) by criteria. 107 | * 108 | * @return int[] 109 | */ 110 | public function search(string $modelName, ?DomainInterface $criteria = null, array $orders = [], int $limit = null, int $offset = null): array 111 | { 112 | return $this 113 | ->getRepository($modelName) 114 | ->search($criteria, $orders, $limit, $offset); 115 | } 116 | 117 | /** 118 | * Find ONE record by ID. 119 | * 120 | * @throws RecordNotFoundException when the record was not found 121 | */ 122 | public function read(string $modelName, int $id, array $fields = []): array 123 | { 124 | return $this 125 | ->getRepository($modelName) 126 | ->read($id, $fields); 127 | } 128 | 129 | /** 130 | * Find ONE record by ID. 131 | */ 132 | public function find(string $modelName, int $id, array $fields = []): ?array 133 | { 134 | return $this 135 | ->getRepository($modelName) 136 | ->find($id, $fields); 137 | } 138 | 139 | /** 140 | * Find ONE record by criteria. 141 | */ 142 | public function findOneBy(string $modelName, ?DomainInterface $criteria = null, array $fields = [], array $orders = [], int $offset = null): ?array 143 | { 144 | return $this 145 | ->getRepository($modelName) 146 | ->findOneBy($criteria, $fields, $orders, $offset); 147 | } 148 | 149 | /** 150 | * Find all records. 151 | * 152 | * @return array[] 153 | */ 154 | public function findAll(string $modelName, array $fields = [], array $orders = [], int $limit = null, int $offset = null): array 155 | { 156 | return $this 157 | ->getRepository($modelName) 158 | ->findAll($fields, $orders, $limit, $offset); 159 | } 160 | 161 | /** 162 | * Find record(s) by criteria. 163 | * 164 | * @return array[] 165 | */ 166 | public function findBy(string $modelName, ?DomainInterface $criteria = null, array $fields = [], array $orders = [], int $limit = null, int $offset = null): array 167 | { 168 | return $this 169 | ->getRepository($modelName) 170 | ->findBy($criteria, $fields, $orders, $limit, $offset); 171 | } 172 | 173 | /** 174 | * Check if a record exists. 175 | */ 176 | public function exists(string $modelName, int $id): bool 177 | { 178 | return $this 179 | ->getRepository($modelName) 180 | ->exists($id); 181 | } 182 | 183 | /** 184 | * Count number of all records for the model. 185 | */ 186 | public function countAll(string $modelName): int 187 | { 188 | return $this 189 | ->getRepository($modelName) 190 | ->countAll(); 191 | } 192 | 193 | /** 194 | * Count number of records for a model and criteria. 195 | */ 196 | public function count(string $modelName, ?DomainInterface $criteria = null): int 197 | { 198 | return $this 199 | ->getRepository($modelName) 200 | ->count($criteria); 201 | } 202 | 203 | public function getRepository(string $modelName): RecordRepository 204 | { 205 | if (!array_key_exists($modelName, $this->repositories)) { 206 | $repository = new RecordRepository($this, $modelName); 207 | $this->addRepository($repository); 208 | 209 | return $repository; 210 | } 211 | 212 | return $this->repositories[$modelName]; 213 | } 214 | 215 | public function setRepositories(array $repositories = []): self 216 | { 217 | $this->repositories = []; 218 | 219 | foreach ($repositories as $repository) { 220 | $this->addRepository($repository); 221 | } 222 | 223 | return $this; 224 | } 225 | 226 | public function addRepository(RecordRepository $repository): self 227 | { 228 | $this->repositories[$repository->getModelName()] = $repository; 229 | $repository->setRecordManager($this); 230 | 231 | return $this; 232 | } 233 | 234 | public function createQueryBuilder(string $modelName = null): QueryBuilder 235 | { 236 | return new QueryBuilder($this, $modelName); 237 | } 238 | 239 | public function createOrmQuery(string $name, string $method): OrmQuery 240 | { 241 | return new OrmQuery($this, $name, $method); 242 | } 243 | 244 | public function createNativeQuery(string $name, string $method): NativeQuery 245 | { 246 | return new NativeQuery($this, $name, $method); 247 | } 248 | 249 | /** 250 | * @return mixed 251 | */ 252 | public function executeQuery(QueryInterface $query) 253 | { 254 | $options = $query->getOptions(); 255 | 256 | if (!$options) { 257 | return $this->client->call($query->getName(), $query->getMethod(), $query->getParameters()); 258 | } 259 | 260 | return $this->client->call($query->getName(), $query->getMethod(), $query->getParameters(), $options); 261 | } 262 | 263 | public function getClient(): Client 264 | { 265 | return $this->client; 266 | } 267 | 268 | public function getSchema(): Schema 269 | { 270 | return $this->schema; 271 | } 272 | 273 | public function getRepositories(): array 274 | { 275 | return $this->repositories; 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /src/DBAL/Repository/RecordNotFoundException.php: -------------------------------------------------------------------------------- 1 | modelName = $modelName; 20 | $this->id = $id; 21 | 22 | parent::__construct(sprintf('No record found for model "%s" with ID #%d.', $modelName, $id)); 23 | } 24 | 25 | public function getModelName(): string 26 | { 27 | return $this->modelName; 28 | } 29 | 30 | public function getId(): int 31 | { 32 | return $this->id; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/DBAL/Repository/RecordRepository.php: -------------------------------------------------------------------------------- 1 | recordManager = $recordManager; 26 | $this->modelName = $modelName; 27 | $recordManager->addRepository($this); 28 | } 29 | 30 | /** 31 | * Insert a new record. 32 | * 33 | * @throws InvalidArgumentException when $data is empty 34 | * 35 | * @return int the ID of the new record 36 | */ 37 | public function insert(array $data): int 38 | { 39 | if (!$data) { 40 | throw new InvalidArgumentException('Data cannot be empty'); 41 | } 42 | 43 | return $this 44 | ->createQueryBuilder() 45 | ->insert() 46 | ->setValues($data) 47 | ->getQuery() 48 | ->execute(); 49 | } 50 | 51 | /** 52 | * Update record(s). 53 | * 54 | * NB: It is not currently possible to perform “computed” updates 55 | * (where the value being set depends on an existing value of a record). 56 | * 57 | * @param array|int $ids 58 | */ 59 | public function update($ids, array $data = []): void 60 | { 61 | if (!$data) { 62 | return; 63 | } 64 | 65 | $this 66 | ->createQueryBuilder() 67 | ->update((array) $ids) 68 | ->setValues($data) 69 | ->getQuery() 70 | ->execute(); 71 | } 72 | 73 | /** 74 | * Delete record(s). 75 | * 76 | * @param array|int $ids 77 | */ 78 | public function delete($ids): void 79 | { 80 | if (!$ids) { 81 | return; 82 | } 83 | 84 | $this 85 | ->createQueryBuilder() 86 | ->delete((array) $ids) 87 | ->getQuery() 88 | ->execute(); 89 | } 90 | 91 | /** 92 | * Search one ID of record by criteria. 93 | */ 94 | public function searchOne(?DomainInterface $criteria): ?int 95 | { 96 | return (int) $this 97 | ->createQueryBuilder() 98 | ->search() 99 | ->where($criteria) 100 | ->getQuery() 101 | ->getOneOrNullScalarResult(); 102 | } 103 | 104 | /** 105 | * Search all ID of record(s). 106 | * 107 | * @return int[] 108 | */ 109 | public function searchAll(array $orders = [], int $limit = null, int $offset = null): array 110 | { 111 | return $this->search(null, $orders, $limit, $offset); 112 | } 113 | 114 | /** 115 | * Search ID of record(s) by criteria. 116 | * 117 | * @return int[] 118 | */ 119 | public function search(?DomainInterface $criteria = null, array $orders = [], int $limit = null, int $offset = null): array 120 | { 121 | /** @var int[] $result */ 122 | $result = $this 123 | ->createQueryBuilder() 124 | ->search() 125 | ->where($criteria) 126 | ->setOrders($orders) 127 | ->setFirstResult($offset) 128 | ->setMaxResults($limit) 129 | ->getQuery() 130 | ->getScalarResult(); 131 | 132 | return $result; 133 | } 134 | 135 | /** 136 | * Find ONE record by ID. 137 | * 138 | * @throws RecordNotFoundException when the record was not found 139 | */ 140 | public function read(int $id, array $fields = []): array 141 | { 142 | $record = $this->find($id, $fields); 143 | 144 | if (!$record) { 145 | throw new RecordNotFoundException($this->modelName, $id); 146 | } 147 | 148 | return $record; 149 | } 150 | 151 | /** 152 | * Find ONE record by ID. 153 | */ 154 | public function find(int $id, array $fields = []): ?array 155 | { 156 | return $this->findOneBy($this->expr()->eq('id', $id), $fields); 157 | } 158 | 159 | /** 160 | * Find ONE record by criteria. 161 | */ 162 | public function findOneBy(?DomainInterface $criteria = null, array $fields = [], array $orders = [], int $offset = null): ?array 163 | { 164 | $result = $this->findBy($criteria, $fields, $orders, 1, $offset); 165 | 166 | return array_pop($result); 167 | } 168 | 169 | /** 170 | * Find all records. 171 | * 172 | * @return array[] 173 | */ 174 | public function findAll(array $fields = [], array $orders = [], int $limit = null, int $offset = null): array 175 | { 176 | return $this->findBy(null, $fields, $orders, $limit, $offset); 177 | } 178 | 179 | /** 180 | * Find record(s) by criteria. 181 | * 182 | * @return array[] 183 | */ 184 | public function findBy(?DomainInterface $criteria = null, array $fields = [], array $orders = [], int $limit = null, int $offset = null): array 185 | { 186 | return $this 187 | ->createQueryBuilder() 188 | ->select($fields) 189 | ->where($criteria) 190 | ->setOrders($orders) 191 | ->setFirstResult($offset) 192 | ->setMaxResults($limit) 193 | ->getQuery() 194 | ->getResult(); 195 | } 196 | 197 | /** 198 | * Check if a record exists. 199 | */ 200 | public function exists(int $id): bool 201 | { 202 | return 1 === $this->count($this->expr()->eq('id', $id)); 203 | } 204 | 205 | /** 206 | * Count number of all records for the model. 207 | */ 208 | public function countAll(): int 209 | { 210 | return $this->count(); 211 | } 212 | 213 | /** 214 | * Count number of records for a model and criteria. 215 | */ 216 | public function count(?DomainInterface $criteria = null): int 217 | { 218 | return $this 219 | ->createQueryBuilder() 220 | ->select() 221 | ->where($criteria) 222 | ->getQuery() 223 | ->count(); 224 | } 225 | 226 | public function createQueryBuilder(): QueryBuilder 227 | { 228 | return $this->recordManager 229 | ->createQueryBuilder($this->modelName) 230 | ->select(); 231 | } 232 | 233 | public function setRecordManager(RecordManager $recordManager): self 234 | { 235 | $this->recordManager = $recordManager; 236 | 237 | return $this; 238 | } 239 | 240 | public function getRecordManager(): RecordManager 241 | { 242 | return $this->recordManager; 243 | } 244 | 245 | public function getModelName(): string 246 | { 247 | return $this->modelName; 248 | } 249 | 250 | public function expr(): ExpressionBuilder 251 | { 252 | return $this->recordManager->getExpressionBuilder(); 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /src/DBAL/Schema/Choice.php: -------------------------------------------------------------------------------- 1 | name = $name; 25 | $this->value = $value; 26 | $this->id = $id; 27 | } 28 | 29 | public function getId(): ?int 30 | { 31 | return $this->id; 32 | } 33 | 34 | public function getName(): string 35 | { 36 | return $this->name; 37 | } 38 | 39 | public function getValue(): string 40 | { 41 | return $this->value; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/DBAL/Schema/Field.php: -------------------------------------------------------------------------------- 1 | id = (int) $data['id']; 89 | $this->name = (string) $data['name']; 90 | $this->type = (string) $data['ttype']; 91 | $this->required = (bool) $data['required']; 92 | $this->readOnly = (bool) $data['readonly']; 93 | $this->displayName = $data['display_name'] ?? null; 94 | $this->size = $data['size'] ?? null; 95 | $this->selection = $data['selection'] ?? null; 96 | $this->targetModelName = $data['relation'] ?? null; 97 | $this->targetFieldName = $data['relation_field'] ?? null; 98 | } 99 | 100 | public function getModel(): Model 101 | { 102 | return $this->model; 103 | } 104 | 105 | public function setModel(Model $model): self 106 | { 107 | $this->model = $model; 108 | 109 | return $this; 110 | } 111 | 112 | public function getId(): ?int 113 | { 114 | return $this->id; 115 | } 116 | 117 | public function getName(): string 118 | { 119 | return $this->name; 120 | } 121 | 122 | public function getType(): string 123 | { 124 | return $this->type; 125 | } 126 | 127 | public function isRequired(): bool 128 | { 129 | return $this->required; 130 | } 131 | 132 | public function isReadOnly(): bool 133 | { 134 | return $this->readOnly; 135 | } 136 | 137 | public function getDisplayName(): string 138 | { 139 | return $this->displayName ?: $this->name; 140 | } 141 | 142 | public function getSize(): ?int 143 | { 144 | return $this->size; 145 | } 146 | 147 | public function getSelection(): ?Selection 148 | { 149 | return $this->selection; 150 | } 151 | 152 | public function getTargetModelName(): ?string 153 | { 154 | return $this->targetModelName; 155 | } 156 | 157 | public function getTargetFieldName(): ?string 158 | { 159 | return $this->targetFieldName; 160 | } 161 | 162 | public function isIdentifier(): bool 163 | { 164 | return 'id' === $this->name; 165 | } 166 | 167 | public function isBinary(): bool 168 | { 169 | return Field::T_BINARY === $this->type; 170 | } 171 | 172 | public function isBoolean(): bool 173 | { 174 | return Field::T_BOOLEAN === $this->type; 175 | } 176 | 177 | public function isInteger(): bool 178 | { 179 | return Field::T_INTEGER === $this->type; 180 | } 181 | 182 | public function isFloat(): bool 183 | { 184 | return in_array($this->type, [self::T_FLOAT, self::T_MONETARY]); 185 | } 186 | 187 | public function isNumber(): bool 188 | { 189 | return $this->isInteger() || $this->isFloat(); 190 | } 191 | 192 | public function isString(): bool 193 | { 194 | return in_array($this->type, [self::T_CHAR, self::T_TEXT, self::T_HTML]); 195 | } 196 | 197 | public function isDate(): bool 198 | { 199 | return in_array($this->type, [self::T_DATE, self::T_DATETIME], true); 200 | } 201 | 202 | public function getDateFormat(): string 203 | { 204 | return Field::T_DATETIME === $this->type ? self::DATETIME_FORMAT : self::DATE_FORMAT; 205 | } 206 | 207 | public function isSelection(): bool 208 | { 209 | return Field::T_SELECTION === $this->type; 210 | } 211 | 212 | public function isSelectable(): bool 213 | { 214 | return null !== $this->selection; 215 | } 216 | 217 | public function isAssociation(): bool 218 | { 219 | return in_array($this->type, [ 220 | self::T_MANY_TO_ONE, 221 | self::T_MANY_TO_MANY, 222 | self::T_ONE_TO_MANY, 223 | ], true); 224 | } 225 | 226 | public function isSingleAssociation(): bool 227 | { 228 | return self::T_MANY_TO_ONE === $this->type; 229 | } 230 | 231 | public function isMultipleAssociation(): bool 232 | { 233 | return in_array($this->type, [ 234 | self::T_MANY_TO_MANY, 235 | self::T_ONE_TO_MANY, 236 | ], true); 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /src/DBAL/Schema/Model.php: -------------------------------------------------------------------------------- 1 | schema = $schema; 40 | $this->id = (int) $data['id']; 41 | $this->name = (string) $data['model']; 42 | $this->displayName = (string) $data['name']; 43 | $this->transient = (bool) $data['transient']; 44 | 45 | foreach ($fields as $field) { 46 | $this->addField($field); 47 | } 48 | } 49 | 50 | public function getSchema(): Schema 51 | { 52 | return $this->schema; 53 | } 54 | 55 | public function getId(): int 56 | { 57 | return $this->id; 58 | } 59 | 60 | public function getName(): string 61 | { 62 | return $this->name; 63 | } 64 | 65 | public function getDisplayName(): string 66 | { 67 | return $this->displayName ?: $this->name; 68 | } 69 | 70 | public function isTransient(): bool 71 | { 72 | return $this->transient; 73 | } 74 | 75 | public function hasField(string $fieldName): bool 76 | { 77 | try { 78 | $this->getField($fieldName); 79 | } catch (SchemaException $exception) { 80 | return false; 81 | } 82 | 83 | return true; 84 | } 85 | 86 | /** 87 | * @throws SchemaException when the field was not found 88 | */ 89 | public function getField(string $fieldName): Field 90 | { 91 | $model = $this; 92 | $fields = explode('.', $fieldName); 93 | $lastKey = count($fields) - 1; 94 | 95 | foreach ($fields as $key => $subFieldName) { 96 | $field = $model->getField($subFieldName); 97 | 98 | if ($lastKey === $key) { 99 | break; 100 | } 101 | 102 | $targetModel = $field->getTargetModelName(); 103 | 104 | if (!$targetModel) { 105 | throw SchemaException::fieldNotFound($fieldName, $this); 106 | } 107 | 108 | $model = $this->schema->getModel($targetModel); 109 | } 110 | 111 | return $field; 112 | } 113 | 114 | public function getFields(): array 115 | { 116 | return $this->fields; 117 | } 118 | 119 | /** 120 | * @return array 121 | */ 122 | public function getFieldNames(): array 123 | { 124 | $fieldNames = []; 125 | 126 | foreach ($this->fields as $field) { 127 | $fieldNames[] = $field->getName(); 128 | } 129 | 130 | return $fieldNames; 131 | } 132 | 133 | /** 134 | * @internal 135 | */ 136 | private function addField(Field $field): void 137 | { 138 | $field->setModel($this); 139 | $this->fields[] = $field; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/DBAL/Schema/Schema.php: -------------------------------------------------------------------------------- 1 | client = $client; 29 | } 30 | 31 | public function getModel(string $modelName): Model 32 | { 33 | if (!$this->hasModel($modelName)) { 34 | throw SchemaException::modelNotFound($modelName); 35 | } 36 | 37 | if (!isset($this->loadedModels[$modelName])) { 38 | $expr = $this->client->expr(); 39 | $modelData = $this->client->call(self::IR_MODEL, OrmQuery::SEARCH_READ, $expr->normalizeDomains($expr->eq('model', $modelName))); 40 | $this->loadedModels[$modelName] = $this->createModel($modelData[0]); 41 | } 42 | 43 | return $this->loadedModels[$modelName]; 44 | } 45 | 46 | public function hasModel(string $modelName): bool 47 | { 48 | return in_array($modelName, $this->getModelNames()); 49 | } 50 | 51 | /** 52 | * Gets all model names. 53 | * 54 | * @return string[] 55 | */ 56 | public function getModelNames(): array 57 | { 58 | if (!$this->modelNames) { 59 | $this->modelNames = array_column($this->client->call(self::IR_MODEL, OrmQuery::SEARCH_READ, [[]], [ 60 | 'fields' => ['model'], 61 | ]), 'model'); 62 | } 63 | 64 | return $this->modelNames; 65 | } 66 | 67 | /** 68 | * @internal 69 | */ 70 | private function createModel(array $modelData): Model 71 | { 72 | $expr = $this->client->expr(); 73 | $fields = $this->client->call( 74 | self::IR_MODEL_FIELDS, 75 | OrmQuery::SEARCH_READ, 76 | $expr->normalizeDomains($expr->eq('model_id', $modelData['id'])) 77 | ); 78 | 79 | foreach ($fields as $key => $fieldData) { 80 | $choices = []; 81 | $selectionsIds = array_filter($fieldData['selection_ids'] ?? []); 82 | 83 | if (!empty($selectionsIds)) { 84 | $choices = $this->client->call( 85 | self::IR_MODEL_FIELD_SELECTION, 86 | OrmQuery::SEARCH_READ, 87 | $expr->normalizeDomains($expr->eq('field_id', $fieldData['id'])) 88 | ); 89 | 90 | foreach ($choices as $index => $choice) { 91 | if (is_array($choice)) { 92 | $choices[$index] = new Choice((string) $choice['name'], $choice['value'], (int) $choice['id']); 93 | } 94 | } 95 | } elseif (!empty($fieldData['selection'])) { 96 | if (preg_match_all('#^\[\s*(\(\'(\w+)\'\,\s*\'(\w+)\'\)\s*\,?\s*)*\s*\]$#', trim($fieldData['selection']), $matches, PREG_SET_ORDER)) { 97 | foreach ($matches as $match) { 98 | if (isset($match[2], $match[3])) { 99 | $choices[] = new Choice((string) $match[3], $match[2]); 100 | } 101 | } 102 | } 103 | } 104 | 105 | if ($choices) { 106 | $fieldData['selection'] = $choices; 107 | } 108 | 109 | $fields[$key] = new Field($fieldData); 110 | } 111 | 112 | return new Model($this, $modelData, $fields); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/DBAL/Schema/SchemaException.php: -------------------------------------------------------------------------------- 1 | getName())); 12 | } 13 | 14 | public static function modelNotFound(string $modelName): self 15 | { 16 | return new self(sprintf('The model "%s" was not found on the database', $modelName)); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/DBAL/Schema/Selection.php: -------------------------------------------------------------------------------- 1 | addChoice($choice); 19 | } 20 | } 21 | 22 | public function getIds(): array 23 | { 24 | $ids = []; 25 | 26 | foreach ($this->choices as $choice) { 27 | $ids[] = $choice->getId(); 28 | } 29 | 30 | return $ids; 31 | } 32 | 33 | public function getNames(): array 34 | { 35 | $names = []; 36 | 37 | foreach ($this->choices as $choice) { 38 | $names[] = $choice->getName(); 39 | } 40 | 41 | return $names; 42 | } 43 | 44 | public function getValues(): array 45 | { 46 | $values = []; 47 | 48 | foreach ($this->choices as $choice) { 49 | $values[] = $choice->getValue(); 50 | } 51 | 52 | return $values; 53 | } 54 | 55 | public function getChoices(): array 56 | { 57 | return $this->choices; 58 | } 59 | 60 | /** 61 | * @internal 62 | */ 63 | private function addChoice(Choice $choice): void 64 | { 65 | $this->choices[] = $choice; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Endpoint.php: -------------------------------------------------------------------------------- 1 | url = $url; 26 | $this->client = new XmlRpcClient($url); 27 | } 28 | 29 | /** 30 | * @throws RequestException when request failed 31 | * 32 | * @return mixed 33 | */ 34 | public function call(string $method, array $args = []) 35 | { 36 | try { 37 | return $this->client->call($method, $args); 38 | } catch (XmlRpcRemoteException $exception) { 39 | if (preg_match('#cannot marshal None unless allow_none is enabled#', $exception->getMessage())) { 40 | return null; 41 | } 42 | 43 | throw RemoteException::create($exception); 44 | } catch (Throwable $exception) { 45 | throw new RequestException($exception->getMessage(), $exception->getCode(), $exception); 46 | } 47 | } 48 | 49 | public function getUrl(): string 50 | { 51 | return $this->url; 52 | } 53 | 54 | public function getClient(): XmlRpcClient 55 | { 56 | return $this->client; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Exception/AuthenticationException.php: -------------------------------------------------------------------------------- 1 | getCode(); 17 | $errorMessage = $remoteException->getMessage(); 18 | 19 | if (preg_match('#Traceback \(most recent call last\)#', $errorMessage)) { 20 | $messages = array_filter(explode("\n", $errorMessage)); 21 | 22 | foreach ($messages as $key => $message) { 23 | $messages[$key] = trim($message); 24 | } 25 | 26 | array_shift($messages); 27 | $messages = array_values($messages); 28 | $messageParts = []; 29 | $trace = []; 30 | 31 | foreach ($messages as $i => $value) { 32 | if (preg_match('#^File "(.*)", line (\d+), in (.*)#', $value, $matches)) { 33 | $trace[$i] = [ 34 | 'file' => $matches[1], 35 | 'line' => (int) $matches[2], 36 | 'method' => $matches[3], 37 | 'statement' => $messages[$i + 1], 38 | ]; 39 | 40 | continue; 41 | } 42 | 43 | if ($i > 0 && !isset($trace[$i - 1]) && preg_match('#\w+#', $messages[$i])) { 44 | $messageParts[] = $value; 45 | } 46 | } 47 | 48 | $exception = new self(implode("\n", $messageParts), $errorCode); 49 | $exception->xmlTrace = array_reverse($trace); 50 | 51 | return $exception; 52 | } 53 | 54 | return new self($errorMessage, $errorCode); 55 | } 56 | 57 | public function getXmlTrace(): array 58 | { 59 | return $this->xmlTrace; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Exception/RequestException.php: -------------------------------------------------------------------------------- 1 | reflector = new Reflector(); 23 | } 24 | 25 | /** 26 | * @throws ReflectionException 27 | */ 28 | protected function createObjectTester(object $object): ObjectTester 29 | { 30 | return new ObjectTester($this, $object); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/Expression/AbstractDomainTest.php: -------------------------------------------------------------------------------- 1 | createObjectTester($comparison) 29 | ->assertPropertyAccessorsAndMutators('fieldName', 'bar') 30 | ->assertPropertyAccessorsAndMutators('operator', Comparison::NOT_EQUAL_TO) 31 | ->assertPropertyAccessorsAndMutators('value', 'mixed') 32 | ; 33 | } 34 | 35 | /** 36 | * @covers ::toArray 37 | */ 38 | public function testToArray(): void 39 | { 40 | $comparison = new Comparison('foo', Comparison::EQUAL_TO, 'bar'); 41 | 42 | $this->assertEquals(['foo', '=', 'bar'], $comparison->toArray()); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/Expression/CompositeDomainTest.php: -------------------------------------------------------------------------------- 1 | createMock(DomainInterface::class); 27 | 28 | $this 29 | ->createObjectTester($domain) 30 | ->assertPropertyAccessorsAndMutators('operator', CompositeDomain::OR) 31 | ->assertPropertyAccessorsAndMutators('domains', $fakeDomain, [ 32 | 'is_collection' => true, 33 | 'adder' => ['name' => 'add'], 34 | 'remover' => ['name' => 'remove'], 35 | 'hasser' => ['name' => 'has'], 36 | ]) 37 | ; 38 | } 39 | 40 | /** 41 | * Data provider for the test for method ::toArray(). 42 | */ 43 | public function provideToArrayDataSet(): array 44 | { 45 | $domainA = $this->createFakeDomain('A'); 46 | $domainB = $this->createFakeDomain('B'); 47 | $domainC = $this->createFakeDomain('C'); 48 | $domainD = $this->createFakeDomain('D'); 49 | $domainE = $this->createFakeDomain('E'); 50 | $domainF = $this->createFakeDomain('F'); 51 | $domainG = $this->createFakeDomain('G'); 52 | $domainH = $this->createFakeDomain('H'); 53 | 54 | $data = [ 55 | // [ , , ], 56 | [ // 0 57 | CompositeDomain::AND, [], [], 58 | ], 59 | [ // 1 60 | CompositeDomain::AND, [$domainA], 61 | $domainA->toArray(), 62 | ], 63 | [ // 2 64 | CompositeDomain::AND, [$domainA, $domainB], 65 | ['&', $domainA->toArray(), $domainB->toArray()], 66 | ], 67 | [ // 3 68 | CompositeDomain::AND, [$domainA, $domainB, $domainC], 69 | ['&', $domainA->toArray(), '&', $domainB->toArray(), $domainC->toArray()], 70 | ], 71 | [ // 4 72 | CompositeDomain::OR, [], [], 73 | ], 74 | [ // 5 75 | CompositeDomain::OR, [$domainA], 76 | $domainA->toArray(), 77 | ], 78 | [ // 6 79 | CompositeDomain::OR, [$domainA, $domainB], 80 | ['|', $domainA->toArray(), $domainB->toArray()], 81 | ], 82 | [ // 7 83 | CompositeDomain::OR, [$domainA, $domainB, $domainC], 84 | ['|', $domainA->toArray(), '|', $domainB->toArray(), $domainC->toArray()], 85 | ], 86 | [ // 8 87 | CompositeDomain::NOT, [], [], 88 | ], 89 | [ // 9 90 | CompositeDomain::NOT, [$domainA], 91 | ['!', $domainA->toArray()], 92 | ], 93 | [ // 10 94 | CompositeDomain::NOT, [$domainA, $domainB], 95 | ['!', '&', $domainA->toArray(), $domainB->toArray()], 96 | ], 97 | [ // 11 98 | CompositeDomain::NOT, [$domainA, $domainB, $domainC], 99 | ['!', '&', $domainA->toArray(), '&', $domainB->toArray(), $domainC->toArray()], 100 | ], 101 | ]; 102 | 103 | /** 104 | * @see https://www.odoo.com/fr_FR/forum/aide-1/question/domain-notation-using-multiple-and-nested-and-2170 105 | */ 106 | $orXA = new CompositeDomain(CompositeDomain::OR, [$domainA, $domainB]); 107 | $orXB = new CompositeDomain(CompositeDomain::OR, [$domainC, $domainD, $domainE]); 108 | $expectedResult = ['&', '|', ['A'], ['B'], '|', ['C'], '|', ['D'], ['E']]; 109 | $data[] = [CompositeDomain::AND, [$orXA, $orXB], $expectedResult]; 110 | 111 | // #13 Final test 112 | $orXA = new CompositeDomain(CompositeDomain::OR, [$domainA, $domainB]); 113 | $orXB = new CompositeDomain(CompositeDomain::OR, [$domainC, $domainD, $domainE]); 114 | $orXC = new CompositeDomain(CompositeDomain::OR, [$domainF, $domainG, $domainH]); 115 | $expectedResult = ['&', '|', ['A'], ['B'], '&', '|', ['C'], '|', ['D'], ['E'], '|', ['F'], '|', ['G'], ['H']]; 116 | $data[] = [CompositeDomain::AND, [$orXA, $orXB, $orXC], $expectedResult]; 117 | 118 | return $data; 119 | } 120 | 121 | /** 122 | * @covers ::toArray 123 | * @dataProvider provideToArrayDataSet 124 | * 125 | * @param mixed $expectedResult 126 | */ 127 | public function testToArray(string $operator, array $domains = [], $expectedResult = null, string $message = ''): void 128 | { 129 | $domain = new CompositeDomain($operator, $domains); 130 | $this->assertEquals($expectedResult, $domain->toArray(), $message); 131 | } 132 | 133 | /** 134 | * @param mixed $expression 135 | */ 136 | protected function createFakeDomain($expression): DomainInterface 137 | { 138 | $fakeDomain = $this->createMock(DomainInterface::class); 139 | $fakeDomain 140 | ->method('toArray') 141 | ->willReturn(is_array($expression) ? $expression : (array) $expression); 142 | 143 | return $fakeDomain; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /tests/Utils/Debugger.php: -------------------------------------------------------------------------------- 1 | null, 57 | self::VALUE => null, 58 | self::RESULT => null, 59 | self::MESSAGE => null, 60 | self::IS_FLUENT => false, 61 | self::IS_COLLECTION => false, 62 | ]; 63 | 64 | /** 65 | * @throws ReflectionException 66 | */ 67 | public function __construct(TestCase $testCase, object $object, array $defaultContext = []) 68 | { 69 | parent::__construct($testCase); 70 | 71 | $this->reflector = new Reflector(); 72 | $this->debugger = new Debugger(); 73 | $this->setObject($object); 74 | $this->defaultContext = array_merge($this->defaultContext, $defaultContext); 75 | } 76 | 77 | /** 78 | * Test the accessors of a property (setter and getter). 79 | * This test also checks if the return value of the getter is equal to the value registered with the setter. 80 | * 81 | * @param mixed $value 82 | */ 83 | public function assertPropertyAccessorsAndMutators(string $propertyName, $value, array $context = []): self 84 | { 85 | $context = $this->getContext($context); 86 | $context[self::VALUE] = $value; 87 | $context[self::MESSAGE] = sprintf('Asserting accessors and mutators for property %s::$%s', $this->class->getShortName(), $propertyName); 88 | 89 | if ($context[self::IS_COLLECTION]) { 90 | $adderContext = $this->getContext($context['adder'] ?? [], $context); 91 | $adder = $this->assertAdder($propertyName, $value, $adderContext); 92 | 93 | $hasserContext = $this->getContext($context['hasser'] ?? [], $context); 94 | $hasser = $this->assertHasser($propertyName, $value, $hasserContext); 95 | 96 | $removerContext = $this->getContext($context['remover'] ?? [], $context); 97 | $remover = $this->assertRemover($propertyName, $value, $removerContext); 98 | } 99 | 100 | $setterContext = $this->getContext($context['setter'] ?? [], $context); 101 | $setter = $this->assertSetter($propertyName, $value, $setterContext); 102 | 103 | $getterContext = $this->getContext($context['getter'] ?? [], $context); 104 | $getter = $this->assertGetter($propertyName, $getterContext); 105 | 106 | return $this; 107 | } 108 | 109 | /** 110 | * @param mixed $value 111 | */ 112 | public function assertHasser(string $propertyName, $value, array $context = []): ?ReflectionMethod 113 | { 114 | $context[self::VALUE] = $value; 115 | $context[self::IS_FLUENT] = false; 116 | $context[self::MESSAGE] = sprintf('Asserting hasser for property %s::$%s', 117 | $this->class->getShortName(), 118 | $propertyName 119 | ); 120 | 121 | return $this->assertPropertyMethod($propertyName, self::ACCESSOR_HAS, $context); 122 | } 123 | 124 | public function assertGetter(string $propertyName, array $context = []): ?ReflectionMethod 125 | { 126 | $context[self::IS_FLUENT] = $context[self::IS_FLUENT] ?? false; 127 | $context[self::MESSAGE] = sprintf('Asserting getter for property %s::$%s', 128 | $this->class->getShortName(), 129 | $propertyName 130 | ); 131 | 132 | return $this->assertPropertyMethod($propertyName, self::ACCESSOR_GET, $context); 133 | } 134 | 135 | /** 136 | * @param mixed $value 137 | */ 138 | public function assertAdder(string $propertyName, $value, array $context = []): ?ReflectionMethod 139 | { 140 | $context[self::VALUE] = $value; 141 | $context[self::IS_FLUENT] = $context[self::IS_FLUENT] ?? true; 142 | $context[self::MESSAGE] = sprintf('Asserting adder for property %s::$%s', 143 | $this->class->getShortName(), 144 | $propertyName 145 | ); 146 | 147 | return $this->assertPropertyMethod($propertyName, self::MUTATOR_ADD, $context); 148 | } 149 | 150 | /** 151 | * @param mixed $value 152 | */ 153 | public function assertRemover(string $propertyName, $value, array $context = []): ?ReflectionMethod 154 | { 155 | $context[self::VALUE] = $value; 156 | $context[self::IS_FLUENT] = $context[self::IS_FLUENT] ?? true; 157 | $context[self::MESSAGE] = sprintf('Asserting remover for property %s::$%s', 158 | $this->class->getShortName(), 159 | $propertyName 160 | ); 161 | 162 | return $this->assertPropertyMethod($propertyName, self::MUTATOR_REMOVE, $context); 163 | } 164 | 165 | /** 166 | * Test and return the setter of a property. 167 | * 168 | * @param mixed $value 169 | */ 170 | public function assertSetter(string $propertyName, $value = null, array $context = []): ?ReflectionMethod 171 | { 172 | $context[self::VALUE] = $value; 173 | $context[self::IS_FLUENT] = $context[self::IS_FLUENT] ?? true; 174 | $context[self::MESSAGE] = sprintf('Asserting setter for property %s::$%s', 175 | $this->class->getShortName(), 176 | $propertyName 177 | ); 178 | 179 | return $this->assertPropertyMethod($propertyName, self::MUTATOR_SET, $context); 180 | } 181 | 182 | /** 183 | * Test and return the specific method of a property. 184 | */ 185 | public function assertPropertyMethod(string $propertyName, string $prefix, array $context = []): ?ReflectionMethod 186 | { 187 | $context = $this->getContext($context); 188 | 189 | try { 190 | $property = $this->reflector->getProperty($this->class, $propertyName); 191 | } catch (ReflectionException $e) { 192 | $this->testCase::fail($this->getContextErrorMessage('The property was not found', $context)); 193 | 194 | return null; 195 | } 196 | 197 | $context[self::NAME] = (string) ($context[self::NAME] ?? null); 198 | $methodName = $context[self::NAME] ?: $this->getPropertyMethodName($property->getName(), $prefix); 199 | $class = $property->getDeclaringClass(); 200 | $tested = []; 201 | 202 | if (!$context[self::NAME] && $context[self::IS_COLLECTION]) { 203 | $methodNames = $this->getSingularNames($property->getName(), $prefix); 204 | 205 | foreach ($methodNames as $value) { 206 | if ($class->hasMethod($value)) { 207 | $methodName = $value; 208 | break; 209 | } 210 | 211 | $tested[] = $value; 212 | } 213 | } else { 214 | $tested[] = $methodName; 215 | } 216 | 217 | try { 218 | $method = $class->getMethod($methodName); 219 | } catch (ReflectionException $e) { 220 | $errorMessage = sprintf('None of methods "%s()" was found', implode('"(), "', $tested)); 221 | $this->testCase::fail($this->getContextErrorMessage($errorMessage, $context)); 222 | 223 | return null; 224 | } 225 | 226 | $this->testCase->addToAssertionCount(1); 227 | 228 | $args = self::MUTATOR_SET === $prefix && $context[self::IS_COLLECTION] ? [$context[self::VALUE]] : $context[self::VALUE]; 229 | $args = self::ACCESSOR_GET === $prefix ? [] : [$args]; 230 | $result = $method->invokeArgs($this->object, $args); 231 | 232 | if ((bool) ($context[self::IS_FLUENT] ?? false)) { 233 | $this->testCase::assertEquals($this->object, $result, $this->getContextErrorMessage(sprintf( 234 | 'The method is fluent and should return the object instance' 235 | ), $context)); 236 | 237 | return $method; 238 | } 239 | 240 | $propertyValue = $property->getValue($this->object); 241 | 242 | switch ($prefix) { 243 | case self::ACCESSOR_GET: 244 | $this->testCase::assertEquals($result, $propertyValue); 245 | break; 246 | 247 | case self::MUTATOR_SET: 248 | $expectedValue = $context[self::IS_COLLECTION] ? [$context[self::VALUE]] : $context[self::VALUE]; 249 | $this->testCase::assertEquals($expectedValue, $propertyValue, $this->getContextErrorMessage( 250 | 'The property value is not equal to the value set', 251 | $context 252 | )); 253 | break; 254 | 255 | case self::MUTATOR_ADD: 256 | case self::MUTATOR_REMOVE: 257 | case self::ACCESSOR_HAS: 258 | if (!is_iterable($propertyValue)) { 259 | $this->testCase::fail($this->getContextErrorMessage(sprintf( 260 | 'The property collection should be iterable, %s declared', 261 | $this->debugger->debugType($propertyValue) 262 | ), $context)); 263 | 264 | return null; 265 | } 266 | 267 | $hasValue = false; 268 | 269 | foreach ($propertyValue as $value) { 270 | if ($value === $context[self::VALUE]) { 271 | $hasValue = true; 272 | break; 273 | } 274 | } 275 | 276 | switch ($prefix) { 277 | case self::ACCESSOR_HAS: 278 | $this->testCase::assertEquals($result, $hasValue, $this->getContextErrorMessage( 279 | sprintf('The property collection %s the value but the hasser returns %s', 280 | $hasValue ? 'contains' : 'does not contain', 281 | $this->debugger->debugBool($result) 282 | ), 283 | $context 284 | )); 285 | break; 286 | case self::MUTATOR_ADD: 287 | $this->testCase::assertTrue($hasValue, $this->getContextErrorMessage( 288 | 'The adder was called but the property collection does not contain the added value', 289 | $context 290 | )); 291 | 292 | return $method; 293 | 294 | case self::MUTATOR_REMOVE: 295 | $this->testCase::assertFalse($hasValue, $this->getContextErrorMessage( 296 | 'The property collection still contains the removed value', 297 | $context 298 | )); 299 | 300 | return $method; 301 | } 302 | break; 303 | } 304 | 305 | return $method; 306 | } 307 | 308 | public function getContextErrorMessage(string $message, array $context = []): string 309 | { 310 | $context = $this->getContext($context); 311 | 312 | return sprintf('%s%s', $context[self::MESSAGE] ? sprintf('[%s] ', $context[self::MESSAGE]) : '', $message); 313 | } 314 | 315 | /** 316 | * @return string[] 317 | */ 318 | public function getSingularNames(string $name, string $prefix = null): array 319 | { 320 | $names = (array) Inflector::singularize($name); 321 | 322 | if ($prefix) { 323 | foreach ($names as $key => $value) { 324 | $names[$key] = $this->getPropertyMethodName($value, $prefix); 325 | } 326 | } 327 | 328 | return $names; 329 | } 330 | 331 | public function getPropertyMethodName(string $propertyName, string $prefix = ''): string 332 | { 333 | $propertyName = preg_replace('#([^A-Za-z]+)#', '', $propertyName); 334 | 335 | return sprintf('%s%s', $prefix, $prefix ? ucfirst($propertyName) : $propertyName); 336 | } 337 | 338 | public function getObject(): object 339 | { 340 | return $this->object; 341 | } 342 | 343 | /** 344 | * @throws ReflectionException 345 | */ 346 | public function setObject(object $object): self 347 | { 348 | $this->object = $object; 349 | $this->class = $this->reflector->getClass($object); 350 | 351 | return $this; 352 | } 353 | 354 | public function getClass(): ReflectionClass 355 | { 356 | return $this->class; 357 | } 358 | 359 | public function getReflector(): Reflector 360 | { 361 | return $this->reflector; 362 | } 363 | 364 | public function setReflector(Reflector $reflector): self 365 | { 366 | $this->reflector = $reflector; 367 | 368 | return $this; 369 | } 370 | 371 | public function getDebugger(): Debugger 372 | { 373 | return $this->debugger; 374 | } 375 | 376 | public function setDebugger(Debugger $debugger): self 377 | { 378 | $this->debugger = $debugger; 379 | 380 | return $this; 381 | } 382 | 383 | public function getDefaultContext(): array 384 | { 385 | return $this->defaultContext; 386 | } 387 | 388 | public function setDefaultContext(array $defaultContext): self 389 | { 390 | $this->defaultContext = $defaultContext; 391 | 392 | return $this; 393 | } 394 | 395 | public function getContext(array $context = [], array $defaultContext = []): array 396 | { 397 | return array_merge($defaultContext ?: $this->defaultContext, $context); 398 | } 399 | } 400 | -------------------------------------------------------------------------------- /tests/Utils/Reflector.php: -------------------------------------------------------------------------------- 1 | getProperty($object, $propertyName)->getValue($object); 20 | } 21 | 22 | /** 23 | * @param mixed $value 24 | * 25 | * @throws ReflectionException 26 | */ 27 | public function setObjectValue(object $object, string $propertyName, $value): ReflectionProperty 28 | { 29 | $property = $this->getProperty($object, $propertyName); 30 | 31 | $property->setValue($object, $value); 32 | 33 | return $property; 34 | } 35 | 36 | /** 37 | * @param object|string $objectOrClass 38 | * 39 | * @throws ReflectionException 40 | */ 41 | public function getMethod($objectOrClass, string $methodName, bool $setAccessible = true): ?ReflectionMethod 42 | { 43 | $class = $this->getClass($objectOrClass); 44 | $method = $class->getMethod($methodName); 45 | 46 | if ($setAccessible) { 47 | $method->setAccessible(true); 48 | } 49 | 50 | return $method; 51 | } 52 | 53 | /** 54 | * @param object|string $objectOrClass 55 | * 56 | * @throws ReflectionException 57 | */ 58 | public function getProperty($objectOrClass, string $propertyName, bool $setAccessible = true): ?ReflectionProperty 59 | { 60 | $class = $this->getClass($objectOrClass); 61 | $property = $class->getProperty($propertyName); 62 | 63 | if ($setAccessible) { 64 | $property->setAccessible(true); 65 | } 66 | 67 | return $property; 68 | } 69 | 70 | /** 71 | * @param object|string $objectOrClass 72 | * 73 | * @throws ReflectionException 74 | */ 75 | public function getClass($objectOrClass): ReflectionClass 76 | { 77 | if (is_string($objectOrClass)) { 78 | /* @var class-string $class */ 79 | $class = $objectOrClass; 80 | 81 | return new ReflectionClass($class); 82 | } 83 | 84 | return $objectOrClass instanceof ReflectionClass ? $objectOrClass : new ReflectionClass($objectOrClass); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /tests/Utils/TestDecorator.php: -------------------------------------------------------------------------------- 1 | testCase = $testCase; 20 | } 21 | 22 | public function getTestCase(): TestCase 23 | { 24 | return $this->testCase; 25 | } 26 | 27 | public function setTestCase(TestCase $testCase): self 28 | { 29 | $this->testCase = $testCase; 30 | 31 | return $this; 32 | } 33 | } 34 | --------------------------------------------------------------------------------