Merge pull request #23148 from totten/5.48-cache-ver-suffix
[civicrm-core.git] / Civi / Api4 / Generic / AbstractSaveAction.php
1 <?php
2
3 /*
4 +--------------------------------------------------------------------+
5 | Copyright CiviCRM LLC. All rights reserved. |
6 | |
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 |
10 +--------------------------------------------------------------------+
11 */
12
13 namespace Civi\Api4\Generic;
14
15 use Civi\Api4\Event\ValidateValuesEvent;
16 use Civi\API\Exception\UnauthorizedException;
17 use Civi\Api4\Utils\CoreUtil;
18
19 /**
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).
28 *
29 * @method $this setRecords(array $records) Set array of records to be saved.
30 * @method array getRecords()
31 * @method $this setDefaults(array $defaults) Array of defaults.
32 * @method array getDefaults()
33 * @method $this setReload(bool $reload) Specify whether complete objects will be returned after saving.
34 * @method bool getReload()
35 * @method $this setMatch(array $match) Specify fields to match for update.
36 * @method bool getMatch()
37 *
38 * @package Civi\Api4\Generic
39 */
40 abstract class AbstractSaveAction extends AbstractAction {
41
42 /**
43 * Array of $ENTITIES to save.
44 *
45 * Should be in the same format as returned by `Get`.
46 *
47 * @var array
48 * @required
49 */
50 protected $records = [];
51
52 /**
53 * Array of default values.
54 *
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,
57 * but updating existing $ENTITIES will overwrite current values with these defaults.
58 *
59 * @var array
60 */
61 protected $defaults = [];
62
63 /**
64 * Reload $ENTITIES after saving.
65 *
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.
69 *
70 * @var bool
71 */
72 protected $reload = FALSE;
73
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
88 /**
89 * @throws \API_Exception
90 * @throws \Civi\API\Exception\UnauthorizedException
91 */
92 protected function validateValues() {
93 $idField = CoreUtil::getIdFieldName($this->getEntityName());
94 // FIXME: There should be a protocol to report a full list of errors... Perhaps a subclass of API_Exception?
95 $unmatched = [];
96 foreach ($this->records as $record) {
97 if (empty($record[$idField])) {
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 }
104
105 if ($this->checkPermissions) {
106 foreach ($this->records as $record) {
107 $action = empty($record[$idField]) ? 'create' : 'update';
108 if (!CoreUtil::checkAccessDelegated($this->getEntityName(), $action, $record, \CRM_Core_Session::getLoggedInContactID() ?: 0)) {
109 throw new UnauthorizedException("ACL check failed");
110 }
111 }
112 }
113
114 $e = new ValidateValuesEvent($this, $this->records, new \CRM_Utils_LazyArray(function() use ($idField) {
115 $existingIds = array_column($this->records, $idField);
116 $existing = civicrm_api4($this->getEntityName(), 'get', [
117 'checkPermissions' => $this->checkPermissions,
118 'where' => [[$idField, 'IN', $existingIds]],
119 ], $idField);
120
121 $result = [];
122 foreach ($this->records as $k => $new) {
123 $old = isset($new[$idField]) ? $existing[$new[$idField]] : NULL;
124 $result[$k] = ['old' => $old, 'new' => $new];
125 }
126 return $result;
127 }));
128 \Civi::dispatcher()->dispatch('civi.api4.validate', $e);
129 if (!empty($e->errors)) {
130 throw $e->toException();
131 }
132 }
133
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)) {
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 }
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
168 /**
169 * @return string
170 * @deprecated
171 */
172 protected function getIdField() {
173 return CoreUtil::getInfoItem($this->getEntityName(), 'primary_key')[0];
174 }
175
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
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
212 }