Merge pull request #23148 from totten/5.48-cache-ver-suffix
[civicrm-core.git] / Civi / Api4 / Generic / AbstractSaveAction.php
CommitLineData
19b53e5b
C
1<?php
2
380f3545
TO
3/*
4 +--------------------------------------------------------------------+
41498ac5 5 | Copyright CiviCRM LLC. All rights reserved. |
380f3545 6 | |
41498ac5
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
19b53e5b
C
13namespace Civi\Api4\Generic;
14
4bf92107 15use Civi\Api4\Event\ValidateValuesEvent;
929a9585
CW
16use Civi\API\Exception\UnauthorizedException;
17use Civi\Api4\Utils\CoreUtil;
4bf92107 18
19b53e5b 19/**
58efbb45
CW
20 * Create or update one or more $ENTITIES.
21 *
22 * Pass an array of one or more $ENTITY to save in the `records` param.
23 *
24 * If creating more than one $ENTITY with similar values, use the `defaults` param.
25 *
26 * Set `reload` if you need the api to return complete records for each saved $ENTITY
27 * (including values that were unchanged in from updated $ENTITIES).
19b53e5b 28 *
121ec912 29 * @method $this setRecords(array $records) Set array of records to be saved.
19b53e5b
C
30 * @method array getRecords()
31 * @method $this setDefaults(array $defaults) Array of defaults.
19b53e5b
C
32 * @method array getDefaults()
33 * @method $this setReload(bool $reload) Specify whether complete objects will be returned after saving.
34 * @method bool getReload()
db11224f
CW
35 * @method $this setMatch(array $match) Specify fields to match for update.
36 * @method bool getMatch()
19b53e5b
C
37 *
38 * @package Civi\Api4\Generic
39 */
40abstract class AbstractSaveAction extends AbstractAction {
41
42 /**
e3c6d5ff 43 * Array of $ENTITIES to save.
19b53e5b 44 *
fc95d9a5 45 * Should be in the same format as returned by `Get`.
19b53e5b
C
46 *
47 * @var array
48 * @required
49 */
50 protected $records = [];
51
52 /**
53 * Array of default values.
54 *
9fe720f0
CW
55 * These defaults will be merged into every $ENTITY in `records` before saving.
56 * Values set in `records` will override these defaults if set in both places,
e3c6d5ff 57 * but updating existing $ENTITIES will overwrite current values with these defaults.
19b53e5b
C
58 *
59 * @var array
60 */
61 protected $defaults = [];
62
63 /**
e3c6d5ff 64 * Reload $ENTITIES after saving.
19b53e5b 65 *
fc95d9a5
CW
66 * By default this action typically returns partial records containing only the fields
67 * that were updated. Set `reload` to `true` to do an additional lookup after saving
68 * to return complete values for every $ENTITY.
19b53e5b
C
69 *
70 * @var bool
71 */
72 protected $reload = FALSE;
73
db11224f
CW
74 /**
75 * Specify fields to match for update.
76 *
77 * Normally each record is either created or updated based on the presence of an `id`.
78 * Specifying `$match` fields will also perform an update if an existing $ENTITY matches all specified fields.
79 *
80 * Note: the fields named in this param should be without any options suffix (e.g. `my_field` not `my_field:name`).
81 * Any options suffixes in the $records will be resolved by the api prior to matching.
82 *
83 * @var array
84 * @optionsCallback getMatchFields
85 */
86 protected $match = [];
87
19b53e5b
C
88 /**
89 * @throws \API_Exception
929a9585 90 * @throws \Civi\API\Exception\UnauthorizedException
19b53e5b
C
91 */
92 protected function validateValues() {
12e4505a 93 $idField = CoreUtil::getIdFieldName($this->getEntityName());
4bf92107 94 // FIXME: There should be a protocol to report a full list of errors... Perhaps a subclass of API_Exception?
19b53e5b
C
95 $unmatched = [];
96 foreach ($this->records as $record) {
29ab318b 97 if (empty($record[$idField])) {
19b53e5b
C
98 $unmatched = array_unique(array_merge($unmatched, $this->checkRequiredFields($record)));
99 }
100 }
101 if ($unmatched) {
102 throw new \API_Exception("Mandatory values missing from Api4 {$this->getEntityName()}::{$this->getActionName()}: " . implode(", ", $unmatched), "mandatory_missing", ["fields" => $unmatched]);
103 }
929a9585
CW
104
105 if ($this->checkPermissions) {
106 foreach ($this->records as $record) {
29ab318b 107 $action = empty($record[$idField]) ? 'create' : 'update';
70da3927 108 if (!CoreUtil::checkAccessDelegated($this->getEntityName(), $action, $record, \CRM_Core_Session::getLoggedInContactID() ?: 0)) {
929a9585
CW
109 throw new UnauthorizedException("ACL check failed");
110 }
111 }
112 }
113
29ab318b
CW
114 $e = new ValidateValuesEvent($this, $this->records, new \CRM_Utils_LazyArray(function() use ($idField) {
115 $existingIds = array_column($this->records, $idField);
9f04ea33
TO
116 $existing = civicrm_api4($this->getEntityName(), 'get', [
117 'checkPermissions' => $this->checkPermissions,
29ab318b
CW
118 'where' => [[$idField, 'IN', $existingIds]],
119 ], $idField);
9f04ea33
TO
120
121 $result = [];
122 foreach ($this->records as $k => $new) {
29ab318b 123 $old = isset($new[$idField]) ? $existing[$new[$idField]] : NULL;
9f04ea33
TO
124 $result[$k] = ['old' => $old, 'new' => $new];
125 }
126 return $result;
127 }));
4bf92107
TO
128 \Civi::dispatcher()->dispatch('civi.api4.validate', $e);
129 if (!empty($e->errors)) {
130 throw $e->toException();
131 }
19b53e5b
C
132 }
133
db11224f
CW
134 /**
135 * Find existing record based on $this->match param
136 *
137 * @param $record
138 */
139 protected function matchExisting(&$record) {
140 $primaryKey = CoreUtil::getIdFieldName($this->getEntityName());
141 if (empty($record[$primaryKey]) && !empty($this->match)) {
142 $where = [];
143 foreach ($record as $key => $val) {
144 if (isset($val) && in_array($key, $this->match, TRUE)) {
26c8e1fe
MW
145 if ($val === '' || is_null($val)) {
146 // If we want to match empty string we have to match on NULL/''
147 $where[] = [$key, 'IS EMPTY'];
148 }
149 else {
150 $where[] = [$key, '=', $val];
151 }
db11224f
CW
152 }
153 }
154 if (count($where) === count($this->match)) {
155 $existing = civicrm_api4($this->getEntityName(), 'get', [
156 'select' => [$primaryKey],
157 'where' => $where,
158 'checkPermissions' => $this->checkPermissions,
159 'limit' => 2,
160 ]);
161 if ($existing->count() === 1) {
162 $record[$primaryKey] = $existing->first()[$primaryKey];
163 }
164 }
165 }
166 }
167
19b53e5b
C
168 /**
169 * @return string
db11224f 170 * @deprecated
19b53e5b
C
171 */
172 protected function getIdField() {
29ab318b 173 return CoreUtil::getInfoItem($this->getEntityName(), 'primary_key')[0];
19b53e5b
C
174 }
175
121ec912
CW
176 /**
177 * Add one or more records to be saved.
178 * @param array ...$records
179 * @return $this
180 */
181 public function addRecord(array ...$records) {
182 $this->records = array_merge($this->records, $records);
183 return $this;
184 }
185
186 /**
187 * Set default value for a field.
188 * @param string $fieldName
189 * @param mixed $defaultValue
190 * @return $this
191 */
192 public function addDefault(string $fieldName, $defaultValue) {
193 $this->defaults[$fieldName] = $defaultValue;
194 return $this;
195 }
196
db11224f
CW
197 /**
198 * Options callback for $this->match
199 * @return array
200 */
201 protected function getMatchFields() {
202 return (array) civicrm_api4($this->getEntityName(), 'getFields', [
203 'checkPermissions' => FALSE,
204 'action' => 'get',
205 'where' => [
206 ['type', 'IN', ['Field', 'Custom']],
207 ['name', 'NOT IN', CoreUtil::getInfoItem($this->getEntityName(), 'primary_key')],
208 ],
209 ], ['name']);
210 }
211
19b53e5b 212}