summaryrefslogtreecommitdiffstatshomepage
path: root/core/lib/Drupal/Core/Update/UpdateHookRegistry.php
blob: 19e64d9878051278e9b43b5e48ee73c18ec1c133 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
<?php

namespace Drupal\Core\Update;

use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
use Drupal\Core\KeyValueStore\KeyValueStoreInterface;

/**
 * Provides module updates versions handling.
 */
class UpdateHookRegistry {

  /**
   * Indicates that a module has not been installed yet.
   */
  public const SCHEMA_UNINSTALLED = -1;

  /**
   * Regular expression to match all possible defined hook_update_N().
   */
  private const FUNC_NAME_REGEXP = '/^(?<module>.+)_update_(?<version>\d+)$/';

  /**
   * A list of enabled modules.
   *
   * @var string[]
   */
  protected $enabledModules;

  /**
   * The system.schema key value storage.
   *
   * @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface
   */
  protected $keyValue;

  /**
   * The core.equivalent_updates key value storage.
   *
   * The key value keys are modules and the value is an array of equivalent
   * updates with the following shape:
   * - The array keys are the equivalent future update numbers.
   * - The value is an array containing two keys:
   *   - 'ran_update': The update that registered the future update as an
   *     equivalent.
   *   - 'future_version_string': The version that provides the future update.
   *
   * @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface
   * @see module.api.php
   */
  protected KeyValueStoreInterface $equivalentUpdates;

  /**
   * A static cache of schema currentVersions per module.
   *
   * Stores schema versions of the modules based on their defined hook_update_N
   * implementations.
   * Example:
   * ```
   * [
   *   'example_module' => [
   *     8000,
   *     8001,
   *     8002
   *   ]
   * ]
   * ```
   *
   * @var int[][]
   * @see \Drupal\Core\Update\UpdateHookRegistry::getAvailableUpdates()
   */
  protected $allAvailableSchemaVersions = [];

  /**
   * Constructs a new UpdateHookRegistry.
   *
   * @param array $module_list
   *   An associative array whose keys are the names of installed modules.
   * @param \Drupal\Core\KeyValueStore\KeyValueFactoryInterface $key_value_factory
   *   The key value factory.
   */
  public function __construct(
    array $module_list,
    KeyValueFactoryInterface $key_value_factory,
  ) {
    $this->enabledModules = array_keys($module_list);
    $this->keyValue = $key_value_factory->get('system.schema');
    $this->equivalentUpdates = $key_value_factory->get('core.equivalent_updates');
  }

  /**
   * Returns an array of available schema versions for a module.
   *
   * @param string $module
   *   A module name.
   *
   * @return int[]
   *   An array of available updates sorted by version. Empty array returned if
   *   no updates available.
   */
  public function getAvailableUpdates(string $module): array {
    if (!isset($this->allAvailableSchemaVersions[$module])) {
      $this->allAvailableSchemaVersions[$module] = [];

      foreach ($this->enabledModules as $enabled_module) {
        $this->allAvailableSchemaVersions[$enabled_module] = [];
      }

      $functions = get_defined_functions();
      // Narrow this down to functions ending with an integer, since all
      // hook_update_N() functions end this way, and there are other
      // possible functions which match '_update_'. We use preg_grep() here
      // since looping through all PHP functions can take significant page
      // execution time and this function is called on every administrative page
      // via system_requirements().
      foreach (preg_grep('/_\d+$/', $functions['user']) as $function) {
        // If this function is a module update function, add it to the list of
        // module updates.
        if (preg_match(self::FUNC_NAME_REGEXP, $function, $matches)) {
          $this->allAvailableSchemaVersions[$matches['module']][] = (int) $matches['version'];
        }
      }
      // Ensure that updates are applied in numerical order.
      array_walk(
        $this->allAvailableSchemaVersions,
        static function (&$module_updates) {
          sort($module_updates, SORT_NUMERIC);
        }
      );
    }

    return $this->allAvailableSchemaVersions[$module];
  }

  /**
   * Returns the currently installed schema version for a module.
   *
   * @param string $module
   *   A module name.
   *
   * @return int
   *   The currently installed schema version, or self::SCHEMA_UNINSTALLED if
   *   the module is not installed.
   */
  public function getInstalledVersion(string $module): int {
    return $this->keyValue->get($module, self::SCHEMA_UNINSTALLED);
  }

  /**
   * Updates the installed version information for a module.
   *
   * @param string $module
   *   A module name.
   * @param int $version
   *   The new schema version.
   *
   * @return self
   *   Returns self to support chained method calls.
   */
  public function setInstalledVersion(string $module, int $version): self {
    $this->keyValue->set($module, $version);
    $this->deleteEquivalentUpdate($module, $version);
    return $this;
  }

  /**
   * Deletes the installed version information for the module.
   *
   * @param string $module
   *   The module name to delete.
   */
  public function deleteInstalledVersion(string $module): void {
    $this->keyValue->delete($module);
    $this->equivalentUpdates->delete($module);
  }

  /**
   * Returns the currently installed schema version for all modules.
   *
   * @return int[]
   *   Array of modules as the keys and values as the currently installed schema
   *   version of corresponding module, or self::SCHEMA_UNINSTALLED if the
   *   module is not installed.
   */
  public function getAllInstalledVersions(): array {
    return $this->keyValue->getAll();
  }

  /**
   * Marks a future update as equivalent to the current update running.
   *
   * Updates can be marked as equivalent when they are backported to a
   * previous, but still supported, major version. For example:
   * - A 2.x hook_update_N() would be added as normal, for example:
   *   MODULE_update_2005().
   * - When that same update is backported to 1.x, it is given its own update
   *   number, for example: MODULE_update_1040(). In this update, a call to
   *   @code
   *   \Drupal::service('update.update_hook_registry')->markFutureUpdateEquivalent(2005, '2.10')
   *   @endcode
   *   is added to ensure that a site that has run this update does not run
   *   MODULE_update_2005().
   *
   * @param int $future_update_number
   *   The future update number.
   * @param string $future_version_string
   *   The version that contains the future update.
   */
  public function markFutureUpdateEquivalent(int $future_update_number, string $future_version_string): void {
    [$module, $ran_update_number] = $this->determineModuleAndVersion();

    if ($ran_update_number > $future_update_number) {
      throw new \LogicException(sprintf(
        'Cannot mark the update %d as an equivalent since it is less than the current update %d for the %s module ',
        $future_update_number, $ran_update_number, $module
      ));
    }

    $data = $this->equivalentUpdates->get($module, []);
    // It does not matter if $data[$future_update_number] is already set. If two
    // updates are causing the same update to be marked as equivalent then the
    // latest information is the correct information to use.
    $data[$future_update_number] = [
      'ran_update' => $ran_update_number,
      'future_version_string' => $future_version_string,
    ];
    $this->equivalentUpdates->set($module, $data);
  }

  /**
   * Gets the EquivalentUpdate object for an update.
   *
   * @param string|null $module
   *   The module providing the update. If this is NULL the update to check will
   *   be determined from the backtrace.
   * @param int|null $version
   *   The update to check. If this is NULL the update to check will
   *   be determined from the backtrace.
   *
   * @return \Drupal\Core\Update\EquivalentUpdate|null
   *   A value object with the equivalent update information or NULL if the
   *   update does not have an equivalent update.
   */
  public function getEquivalentUpdate(?string $module = NULL, ?int $version = NULL): ?EquivalentUpdate {
    if ($module === NULL || $version === NULL) {
      [$module, $version] = $this->determineModuleAndVersion();
    }
    $data = $this->equivalentUpdates->get($module, []);
    if (isset($data[$version]['ran_update'])) {
      return new EquivalentUpdate(
        $module,
        $version,
        $data[$version]['ran_update'],
        $data[$version]['future_version_string'],
      );
    }
    return NULL;
  }

  /**
   * Determines the module and update number from the stack trace.
   *
   * @return array<string, int>
   *   An array with two values. The first value is the module name and the
   *   second value is the update number.
   */
  private function determineModuleAndVersion(): array {
    $stack = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);

    for ($i = 0; $i < count($stack); $i++) {
      if (preg_match(self::FUNC_NAME_REGEXP, $stack[$i]['function'], $matches)) {
        return [$matches['module'], $matches['version']];
      }
    }

    throw new \BadMethodCallException(__METHOD__ . ' must be called from a hook_update_N() function');
  }

  /**
   * Removes an equivalent update.
   *
   * @param string $module
   *   The module providing the update.
   * @param int $version
   *   The equivalent update to remove.
   *
   * @return bool
   *   TRUE if an equivalent update was removed, or FALSE if it was not.
   */
  protected function deleteEquivalentUpdate(string $module, int $version): bool {
    $data = $this->equivalentUpdates->get($module, []);
    if (isset($data[$version])) {
      unset($data[$version]);
      if (empty($data)) {
        $this->equivalentUpdates->delete($module);
      }
      else {
        $this->equivalentUpdates->set($module, $data);
      }
      return TRUE;
    }
    return FALSE;
  }

  /**
   * Returns the equivalent update information for all modules.
   *
   * @return array<string, array<int, array{ran_update:int, future_version_string: string}>>
   *   Array of modules as the keys and values as arrays of equivalent update
   *   information.
   */
  public function getAllEquivalentUpdates(): array {
    return $this->equivalentUpdates->getAll();
  }

}