APIv4 - Fix saving NULL as custom field value
[civicrm-core.git] / tests / phpunit / api / v4 / Action / BasicActionsTest.php
CommitLineData
19b53e5b
C
1<?php
2
380f3545
TO
3/*
4 +--------------------------------------------------------------------+
7d61e75f 5 | Copyright CiviCRM LLC. All rights reserved. |
380f3545 6 | |
7d61e75f
TO
7 | This work is published under the GNU AGPLv3 license with some |
8 | permitted exceptions and without any warranty. For full license |
9 | and copyright information, see https://civicrm.org/licensing |
380f3545
TO
10 +--------------------------------------------------------------------+
11 */
12
13/**
14 *
15 * @package CRM
ca5cec67 16 * @copyright CiviCRM LLC https://civicrm.org/licensing
380f3545
TO
17 */
18
19
19b53e5b
C
20namespace api\v4\Action;
21
22use api\v4\UnitTestCase;
23use Civi\Api4\MockBasicEntity;
0b2471a8
CW
24use Civi\Api4\Utils\CoreUtil;
25use Civi\Core\Event\GenericHookEvent;
26use Civi\Test\HookInterface;
19b53e5b
C
27
28/**
29 * @group headless
30 */
0b2471a8
CW
31class BasicActionsTest extends UnitTestCase implements HookInterface {
32
33 /**
34 * Listens for civi.api4.entityTypes event to manually add this nonstandard entity
35 *
36 * @param \Civi\Core\Event\GenericHookEvent $e
37 */
38 public function on_civi_api4_entityTypes(GenericHookEvent $e): void {
39 $e->entities['MockBasicEntity'] = MockBasicEntity::getInfo();
40 }
41
42 public function setUpHeadless() {
43 // Ensure MockBasicEntity gets added via above listener
44 \Civi::cache('metadata')->clear();
45 return parent::setUpHeadless();
46 }
19b53e5b 47
c0e68893 48 private function replaceRecords(&$records) {
482a26e2 49 MockBasicEntity::delete()->addWhere('identifier', '>', 0)->execute();
c0e68893 50 foreach ($records as &$record) {
482a26e2 51 $record['identifier'] = MockBasicEntity::create()->setValues($record)->execute()->first()['identifier'];
c0e68893 52 }
53 }
54
482a26e2
CW
55 public function testGetInfo() {
56 $info = MockBasicEntity::getInfo();
57 $this->assertEquals('MockBasicEntity', $info['name']);
b675a457 58 $this->assertEquals(['identifier'], $info['primary_key']);
0b2471a8 59 $this->assertEquals('identifier', CoreUtil::getIdFieldName('MockBasicEntity'));
482a26e2
CW
60 }
61
19b53e5b 62 public function testCrud() {
482a26e2 63 MockBasicEntity::delete()->addWhere('identifier', '>', 0)->execute();
19b53e5b 64
482a26e2 65 $id1 = MockBasicEntity::create()->addValue('foo', 'one')->execute()->first()['identifier'];
19b53e5b
C
66
67 $result = MockBasicEntity::get()->execute();
68 $this->assertCount(1, $result);
69
29ab318b
CW
70 $id2 = MockBasicEntity::create()
71 ->addValue('foo', 'two')
72 ->addValue('group:label', 'First')
73 ->execute()->first()['identifier'];
19b53e5b
C
74
75 $result = MockBasicEntity::get()->selectRowCount()->execute();
76 $this->assertEquals(2, $result->count());
77
29ab318b
CW
78 // Updating a single record should support identifier either in the values or the where clause
79 // Test both styles of update
482a26e2 80 MockBasicEntity::update()->addWhere('identifier', '=', $id2)->addValue('foo', 'new')->execute();
29ab318b 81 MockBasicEntity::update()->addValue('identifier', $id2)->addValue('color', 'red')->execute();
19b53e5b 82
482a26e2 83 $result = MockBasicEntity::get()->addOrderBy('identifier', 'DESC')->setLimit(1)->execute();
651c4c95
CW
84 // The object's count() method will account for all results, ignoring limit, while the array results are limited
85 $this->assertCount(2, $result);
86 $this->assertCount(1, (array) $result);
19b53e5b 87 $this->assertEquals('new', $result->first()['foo']);
29ab318b
CW
88 $this->assertEquals('red', $result->first()['color']);
89 $this->assertEquals('one', $result->first()['group']);
19b53e5b
C
90
91 $result = MockBasicEntity::save()
482a26e2
CW
92 ->addRecord(['identifier' => $id1, 'foo' => 'one updated', 'weight' => '5'])
93 ->addRecord(['identifier' => $id2, 'group:label' => 'Second'])
19b53e5b
C
94 ->addRecord(['foo' => 'three'])
95 ->addDefault('color', 'pink')
96 ->setReload(TRUE)
97 ->execute()
482a26e2 98 ->indexBy('identifier');
19b53e5b 99
961e974c 100 $this->assertTrue(5 === $result[$id1]['weight']);
19b53e5b 101 $this->assertEquals('new', $result[$id2]['foo']);
961e974c 102 $this->assertEquals('two', $result[$id2]['group']);
19b53e5b
C
103 $this->assertEquals('three', $result->last()['foo']);
104 $this->assertCount(3, $result);
105 foreach ($result as $item) {
106 $this->assertEquals('pink', $item['color']);
107 }
108
482a26e2 109 $ent1 = MockBasicEntity::get()->addWhere('identifier', '=', $id1)->execute()->first();
961e974c
CW
110 $this->assertEquals('one updated', $ent1['foo']);
111 $this->assertFalse(isset($ent1['group:label']));
112
113 $ent2 = MockBasicEntity::get()->addWhere('group:label', '=', 'Second')->addSelect('group:label', 'group')->execute()->first();
114 $this->assertEquals('two', $ent2['group']);
115 $this->assertEquals('Second', $ent2['group:label']);
116 // We didn't select this
117 $this->assertFalse(isset($ent2['group:name']));
118
119 // With no SELECT, all fields should be returned but not suffixy stuff like group:name
120 $ent2 = MockBasicEntity::get()->addWhere('group:label', '=', 'Second')->execute()->first();
121 $this->assertEquals('two', $ent2['group']);
122 $this->assertFalse(isset($ent2['group:name']));
123 // This one wasn't selected but did get used by the WHERE clause; ensure it isn't returned
124 $this->assertFalse(isset($ent2['group:label']));
19b53e5b 125
482a26e2 126 MockBasicEntity::delete()->addWhere('identifier', '=', $id2);
19b53e5b
C
127 $result = MockBasicEntity::get()->execute();
128 $this->assertEquals('one updated', $result->first()['foo']);
129 }
130
131 public function testReplace() {
19b53e5b
C
132 $objects = [
133 ['group' => 'one', 'color' => 'red'],
134 ['group' => 'one', 'color' => 'blue'],
135 ['group' => 'one', 'color' => 'green'],
136 ['group' => 'two', 'color' => 'orange'],
137 ];
138
c0e68893 139 $this->replaceRecords($objects);
19b53e5b
C
140
141 // Keep red, change blue, delete green, and add yellow
142 $replacements = [
482a26e2
CW
143 ['color' => 'red', 'identifier' => $objects[0]['identifier']],
144 ['color' => 'not blue', 'identifier' => $objects[1]['identifier']],
19b53e5b
C
145 ['color' => 'yellow'],
146 ];
147
148 MockBasicEntity::replace()->addWhere('group', '=', 'one')->setRecords($replacements)->execute();
149
482a26e2 150 $newObjects = MockBasicEntity::get()->addOrderBy('identifier', 'DESC')->execute()->indexBy('identifier');
19b53e5b
C
151
152 $this->assertCount(4, $newObjects);
153
154 $this->assertEquals('yellow', $newObjects->first()['color']);
155
482a26e2 156 $this->assertEquals('not blue', $newObjects[$objects[1]['identifier']]['color']);
19b53e5b
C
157
158 // Ensure group two hasn't been altered
482a26e2
CW
159 $this->assertEquals('orange', $newObjects[$objects[3]['identifier']]['color']);
160 $this->assertEquals('two', $newObjects[$objects[3]['identifier']]['group']);
19b53e5b
C
161 }
162
163 public function testBatchFrobnicate() {
19b53e5b
C
164 $objects = [
165 ['group' => 'one', 'color' => 'red', 'number' => 10],
166 ['group' => 'one', 'color' => 'blue', 'number' => 20],
167 ['group' => 'one', 'color' => 'green', 'number' => 30],
168 ['group' => 'two', 'color' => 'blue', 'number' => 40],
169 ];
c0e68893 170 $this->replaceRecords($objects);
19b53e5b
C
171
172 $result = MockBasicEntity::batchFrobnicate()->addWhere('color', '=', 'blue')->execute();
173 $this->assertEquals(2, count($result));
174 $this->assertEquals([400, 1600], \CRM_Utils_Array::collect('frobnication', (array) $result));
175 }
176
177 public function testGetFields() {
178 $getFields = MockBasicEntity::getFields()->execute()->indexBy('name');
179
60a62215 180 $this->assertCount(8, $getFields);
482a26e2 181 $this->assertEquals('Identifier', $getFields['identifier']['title']);
19b53e5b
C
182 // Ensure default data type is "String" when not specified
183 $this->assertEquals('String', $getFields['color']['data_type']);
184
185 // Getfields should default to loadOptions = false and reduce them to bool
186 $this->assertTrue($getFields['group']['options']);
3ffbd21c 187 $this->assertTrue($getFields['fruit']['options']);
482a26e2 188 $this->assertFalse($getFields['identifier']['options']);
19b53e5b 189
b1b7d409
CW
190 // Getfields should figure out what suffixes are available based on option keys
191 $this->assertEquals(['name', 'label'], $getFields['group']['suffixes']);
192 $this->assertEquals(['name', 'label', 'color'], $getFields['fruit']['suffixes']);
193
3ffbd21c 194 // Load simple options
19b53e5b 195 $getFields = MockBasicEntity::getFields()
3ffbd21c 196 ->addWhere('name', 'IN', ['group', 'fruit'])
19b53e5b
C
197 ->setLoadOptions(TRUE)
198 ->execute()->indexBy('name');
199
3ffbd21c 200 $this->assertCount(2, $getFields);
19b53e5b 201 $this->assertArrayHasKey('one', $getFields['group']['options']);
3ffbd21c
CW
202 // Complex options should be reduced to simple array
203 $this->assertArrayHasKey(1, $getFields['fruit']['options']);
204 $this->assertEquals('Banana', $getFields['fruit']['options'][3]);
205
206 // Load complex options
207 $getFields = MockBasicEntity::getFields()
208 ->addWhere('name', 'IN', ['group', 'fruit'])
209 ->setLoadOptions(['id', 'name', 'label', 'color'])
210 ->execute()->indexBy('name');
211
212 // Simple options should be expanded to non-assoc array
213 $this->assertCount(2, $getFields);
214 $this->assertEquals('one', $getFields['group']['options'][0]['id']);
215 $this->assertEquals('First', $getFields['group']['options'][0]['name']);
216 $this->assertEquals('First', $getFields['group']['options'][0]['label']);
217 $this->assertFalse(isset($getFields['group']['options'][0]['color']));
218 // Complex options should give all requested properties
219 $this->assertEquals('Banana', $getFields['fruit']['options'][2]['label']);
220 $this->assertEquals('yellow', $getFields['fruit']['options'][2]['color']);
19b53e5b
C
221 }
222
223 public function testItemsToGet() {
224 $get = MockBasicEntity::get()
225 ->addWhere('color', 'NOT IN', ['yellow'])
226 ->addWhere('color', 'IN', ['red', 'blue'])
227 ->addWhere('color', '!=', 'green')
228 ->addWhere('group', '=', 'one')
229 ->addWhere('size', 'LIKE', 'big')
230 ->addWhere('shape', 'LIKE', '%a');
231
232 $itemsToGet = new \ReflectionMethod($get, '_itemsToGet');
233 $itemsToGet->setAccessible(TRUE);
234
235 $this->assertEquals(['red', 'blue'], $itemsToGet->invoke($get, 'color'));
236 $this->assertEquals(['one'], $itemsToGet->invoke($get, 'group'));
237 $this->assertEquals(['big'], $itemsToGet->invoke($get, 'size'));
238 $this->assertEmpty($itemsToGet->invoke($get, 'shape'));
239 $this->assertEmpty($itemsToGet->invoke($get, 'weight'));
240 }
241
242 public function testFieldsToGet() {
243 $get = MockBasicEntity::get()
244 ->addWhere('color', '!=', 'green');
245
246 $isFieldSelected = new \ReflectionMethod($get, '_isFieldSelected');
247 $isFieldSelected->setAccessible(TRUE);
248
249 // If no "select" is set, should always return true
250 $this->assertTrue($isFieldSelected->invoke($get, 'color'));
251 $this->assertTrue($isFieldSelected->invoke($get, 'shape'));
07d6d25b 252 $this->assertTrue($isFieldSelected->invoke($get, 'size', 'color', 'shape'));
19b53e5b
C
253
254 // With a non-empty "select" fieldsToSelect() will return fields needed to evaluate each clause.
482a26e2 255 $get->addSelect('identifier');
07d6d25b 256 $this->assertTrue($isFieldSelected->invoke($get, 'color', 'shape', 'size'));
482a26e2 257 $this->assertTrue($isFieldSelected->invoke($get, 'identifier'));
07d6d25b 258 $this->assertFalse($isFieldSelected->invoke($get, 'shape', 'size', 'weight'));
19b53e5b
C
259 $this->assertFalse($isFieldSelected->invoke($get, 'group'));
260
261 $get->addClause('OR', ['shape', '=', 'round'], ['AND', [['size', '=', 'big'], ['weight', '!=', 'small']]]);
262 $this->assertTrue($isFieldSelected->invoke($get, 'color'));
482a26e2 263 $this->assertTrue($isFieldSelected->invoke($get, 'identifier'));
19b53e5b
C
264 $this->assertTrue($isFieldSelected->invoke($get, 'shape'));
265 $this->assertTrue($isFieldSelected->invoke($get, 'size'));
07d6d25b 266 $this->assertTrue($isFieldSelected->invoke($get, 'group', 'weight'));
19b53e5b
C
267 $this->assertFalse($isFieldSelected->invoke($get, 'group'));
268
269 $get->addOrderBy('group');
270 $this->assertTrue($isFieldSelected->invoke($get, 'group'));
271 }
272
b0932d1e 273 public function testWildcardSelect() {
b0932d1e
CW
274 $records = [
275 ['group' => 'one', 'color' => 'red', 'shape' => 'round', 'size' => 'med', 'weight' => 10],
276 ['group' => 'two', 'color' => 'blue', 'shape' => 'round', 'size' => 'med', 'weight' => 20],
277 ];
c0e68893 278 $this->replaceRecords($records);
b0932d1e
CW
279
280 foreach (MockBasicEntity::get()->addSelect('*')->execute() as $result) {
281 ksort($result);
dc36dc4d 282 $this->assertEquals(['color', 'foo', 'fruit', 'group', 'identifier', 'shape', 'size', 'weight'], array_keys($result));
b0932d1e
CW
283 }
284
285 $result = MockBasicEntity::get()
29ab318b 286 ->addSelect('s*e', 'weig*ht')
b0932d1e
CW
287 ->execute()
288 ->first();
289 $this->assertEquals(['shape', 'size', 'weight'], array_keys($result));
290 }
291
c0e68893 292 public function testEmptyAndNullOperators() {
293 $records = [
929a9585 294 ['id' => NULL],
c0e68893 295 ['color' => '', 'weight' => 0],
296 ['color' => 'yellow', 'weight' => 100000000000],
297 ];
298 $this->replaceRecords($records);
299
300 $result = MockBasicEntity::get()
301 ->addWhere('color', 'IS NULL')
482a26e2 302 ->execute()->indexBy('identifier');
c0e68893 303 $this->assertCount(1, $result);
482a26e2 304 $this->assertArrayHasKey($records[0]['identifier'], (array) $result);
39deabd6 305
c0e68893 306 $result = MockBasicEntity::get()
307 ->addWhere('color', 'IS EMPTY')
482a26e2 308 ->execute()->indexBy('identifier');
c0e68893 309 $this->assertCount(2, $result);
482a26e2 310 $this->assertArrayNotHasKey($records[2]['identifier'], (array) $result);
c0e68893 311
312 $result = MockBasicEntity::get()
313 ->addWhere('color', 'IS NOT EMPTY')
482a26e2 314 ->execute()->indexBy('identifier');
c0e68893 315 $this->assertCount(1, $result);
482a26e2 316 $this->assertArrayHasKey($records[2]['identifier'], (array) $result);
c0e68893 317
318 $result = MockBasicEntity::get()
319 ->addWhere('weight', 'IS NULL')
482a26e2 320 ->execute()->indexBy('identifier');
c0e68893 321 $this->assertCount(1, $result);
482a26e2 322 $this->assertArrayHasKey($records[0]['identifier'], (array) $result);
c0e68893 323
324 $result = MockBasicEntity::get()
325 ->addWhere('weight', 'IS EMPTY')
482a26e2 326 ->execute()->indexBy('identifier');
c0e68893 327 $this->assertCount(2, $result);
482a26e2 328 $this->assertArrayNotHasKey($records[2]['identifier'], (array) $result);
c0e68893 329
330 $result = MockBasicEntity::get()
331 ->addWhere('weight', 'IS NOT EMPTY')
482a26e2 332 ->execute()->indexBy('identifier');
c0e68893 333 $this->assertCount(1, $result);
482a26e2 334 $this->assertArrayHasKey($records[2]['identifier'], (array) $result);
c0e68893 335 }
336
337 public function testContainsOperator() {
39deabd6
CW
338 $records = [
339 ['group' => 'one', 'fruit:name' => ['apple', 'pear'], 'weight' => 11],
340 ['group' => 'two', 'fruit:name' => ['pear', 'banana'], 'weight' => 12],
341 ];
c0e68893 342 $this->replaceRecords($records);
39deabd6
CW
343
344 $result = MockBasicEntity::get()
345 ->addWhere('fruit:name', 'CONTAINS', 'apple')
346 ->execute();
347 $this->assertCount(1, $result);
348 $this->assertEquals('one', $result->first()['group']);
349
350 $result = MockBasicEntity::get()
351 ->addWhere('fruit:name', 'CONTAINS', 'pear')
352 ->execute();
353 $this->assertCount(2, $result);
354
355 $result = MockBasicEntity::get()
356 ->addWhere('group', 'CONTAINS', 'o')
357 ->execute();
358 $this->assertCount(2, $result);
359
360 $result = MockBasicEntity::get()
361 ->addWhere('weight', 'CONTAINS', 1)
362 ->execute();
363 $this->assertCount(2, $result);
364
365 $result = MockBasicEntity::get()
366 ->addWhere('fruit:label', 'CONTAINS', 'Banana')
367 ->execute();
368 $this->assertCount(1, $result);
369 $this->assertEquals('two', $result->first()['group']);
370
371 $result = MockBasicEntity::get()
372 ->addWhere('weight', 'CONTAINS', 2)
373 ->execute();
374 $this->assertCount(1, $result);
375 $this->assertEquals('two', $result->first()['group']);
376 }
377
b5599ad6
PF
378 public function testRegexpOperators() {
379 $records = [
380 ['color' => 'red'],
381 ['color' => 'blue'],
382 ['color' => 'brown'],
383 ];
384 $this->replaceRecords($records);
385
386 $result = MockBasicEntity::get()
387 ->addWhere('color', 'REGEXP', '^b')
388 ->execute();
389 $this->assertCount(2, $result);
390 $this->assertEquals('blue', $result[0]['color']);
391 $this->assertEquals('brown', $result[1]['color']);
392
393 $result = MockBasicEntity::get()
394 ->addWhere('color', 'NOT REGEXP', '^b')
395 ->execute();
396 $this->assertCount(1, $result);
397 $this->assertEquals('red', $result[0]['color']);
398 }
399
3ffbd21c 400 public function testPseudoconstantMatch() {
3ffbd21c
CW
401 $records = [
402 ['group:label' => 'First', 'shape' => 'round', 'fruit:name' => 'banana'],
403 ['group:name' => 'Second', 'shape' => 'square', 'fruit:label' => 'Pear'],
404 ];
c0e68893 405 $this->replaceRecords($records);
3ffbd21c
CW
406
407 $results = MockBasicEntity::get()
408 ->addSelect('*', 'group:label', 'group:name', 'fruit:name', 'fruit:color', 'fruit:label')
a4499ec5 409 ->addOrderBy('fruit:color', "DESC")
3ffbd21c
CW
410 ->execute();
411
412 $this->assertEquals('round', $results[0]['shape']);
413 $this->assertEquals('one', $results[0]['group']);
414 $this->assertEquals('First', $results[0]['group:label']);
415 $this->assertEquals('First', $results[0]['group:name']);
416 $this->assertEquals(3, $results[0]['fruit']);
417 $this->assertEquals('Banana', $results[0]['fruit:label']);
418 $this->assertEquals('banana', $results[0]['fruit:name']);
419 $this->assertEquals('yellow', $results[0]['fruit:color']);
420
a4499ec5
CW
421 // Reverse order
422 $results = MockBasicEntity::get()
423 ->addOrderBy('fruit:color')
424 ->execute();
425 $this->assertEquals('two', $results[0]['group']);
426
3ffbd21c
CW
427 // Cannot match to a non-unique option property like :color on create
428 try {
429 MockBasicEntity::create()->addValue('fruit:color', 'yellow')->execute();
430 }
431 catch (\API_Exception $createError) {
432 }
df347a8c 433 $this->assertStringContainsString('Illegal expression', $createError->getMessage());
3ffbd21c
CW
434 }
435
19b53e5b 436}