-
Notifications
You must be signed in to change notification settings - Fork 690
Expand file tree
/
Copy pathProjectConfig.php
More file actions
2257 lines (1977 loc) · 74.6 KB
/
ProjectConfig.php
File metadata and controls
2257 lines (1977 loc) · 74.6 KB
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
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<?php
/**
* @link https://craftcms.com/
* @copyright Copyright (c) Pixel & Tonic, Inc.
* @license https://craftcms.github.io/license/
*/
namespace craft\services;
use Craft;
use craft\db\Query;
use craft\db\Table;
use craft\elements\Address;
use craft\elements\User;
use craft\errors\BusyResourceException;
use craft\errors\OperationAbortedException;
use craft\errors\StaleResourceException;
use craft\events\ConfigEvent;
use craft\events\RebuildConfigEvent;
use craft\helpers\ArrayHelper;
use craft\helpers\DateTimeHelper;
use craft\helpers\Db;
use craft\helpers\FileHelper;
use craft\helpers\Json;
use craft\helpers\ProjectConfig as ProjectConfigHelper;
use craft\helpers\StringHelper;
use craft\models\ProjectConfigData;
use craft\models\ReadOnlyProjectConfigData;
use Symfony\Component\Yaml\Yaml;
use Throwable;
use yii\base\Application;
use yii\base\Component;
use yii\base\ErrorException;
use yii\base\Exception;
use yii\base\InvalidArgumentException;
use yii\base\InvalidConfigException;
use yii\base\NotSupportedException;
use yii\caching\ExpressionDependency;
use yii\web\ServerErrorHttpException;
/**
* Project Config service.
*
* An instance of the service is available via [[\craft\base\ApplicationTrait::getProjectConfig()|`Craft::$app->getProjectConfig()`]].
*
* @property-read bool $isApplyingExternalChanges
* @property-read bool $isApplyingYamlChanges
* @author Pixel & Tonic, Inc. <[email protected]>
* @since 3.1.0
*/
class ProjectConfig extends Component
{
/**
* The cache key that is used to store the modified time of the project config files, at the time they were last applied.
*/
public const CACHE_KEY = 'projectConfig:files';
/**
* The cache key that is used to store the modified time of the project config files, at the time they were last applied or ignored.
*
* @since 3.5.0
* @deprecated in 5.6.6
*/
public const IGNORE_CACHE_KEY = 'projectConfig:ignore';
/**
* The cache key that is used to store the loaded project config data.
*/
public const STORED_CACHE_KEY = 'projectConfig:internal';
/**
* The cache key that is used to store whether there were any issues writing the project config files out.
*
* @since 3.5.0
*/
public const FILE_ISSUES_CACHE_KEY = 'projectConfig:fileIssues';
/**
* The cache key that is used to store the current project config diff
*
* @since 3.5.8
*/
public const DIFF_CACHE_KEY = 'projectConfig:diff';
/**
* The duration that project config caches should be cached.
*/
public const CACHE_DURATION = 31536000; // 1 year
/**
* @var string Filename for base config file
* @since 3.1.0
*/
public const CONFIG_FILENAME = 'project.yaml';
/**
* Filename for base config delta files
*
* @since 3.4.0
*/
public const CONFIG_DELTA_FILENAME = 'delta.yaml';
/**
* The array key to use for signaling ordered-to-associative array conversion.
*/
public const ASSOC_KEY = '__assoc__';
/**
* @see _acquireLock()
* @see _releaseLock()
* @since 3.7.35
*/
public const MUTEX_NAME = 'project-config';
public const PATH_ADDRESSES = 'addresses';
public const PATH_ADDRESS_FIELD_LAYOUTS = self::PATH_ADDRESSES . '.' . 'fieldLayouts';
public const PATH_CATEGORY_GROUPS = 'categoryGroups';
public const PATH_DATE_MODIFIED = 'dateModified';
public const PATH_ELEMENT_SOURCES = 'elementSources';
/** @since 5.9.0 */
public const PATH_ELEMENT_SOURCE_PAGES = 'elementSourcesPages';
public const PATH_ENTRY_TYPES = 'entryTypes';
public const PATH_FIELDS = 'fields';
public const PATH_GLOBAL_SETS = 'globalSets';
public const PATH_FS = 'fs';
public const PATH_GRAPHQL = 'graphql';
public const PATH_GRAPHQL_PUBLIC_TOKEN = self::PATH_GRAPHQL . '.' . 'publicToken';
public const PATH_GRAPHQL_SCHEMAS = self::PATH_GRAPHQL . '.' . 'schemas';
public const PATH_IMAGE_TRANSFORMS = 'imageTransforms';
/** @since 4.4.17 */
public const PATH_META = 'meta';
public const PATH_META_NAMES = self::PATH_META . '.__names__';
public const PATH_PLUGINS = 'plugins';
public const PATH_ROUTES = 'routes';
public const PATH_SCHEMA_VERSION = self::PATH_SYSTEM . '.schemaVersion';
public const PATH_SECTIONS = 'sections';
public const PATH_SITES = 'sites';
public const PATH_SITE_GROUPS = 'siteGroups';
public const PATH_SYSTEM = 'system';
public const PATH_TAG_GROUPS = 'tagGroups';
public const PATH_USERS = 'users';
public const PATH_USER_FIELD_LAYOUTS = self::PATH_USERS . '.' . 'fieldLayouts';
public const PATH_USER_GROUPS = self::PATH_USERS . '.groups';
public const PATH_VOLUMES = 'volumes';
// Regexp patterns
// -------------------------------------------------------------------------
/**
* Regexp pattern to determine a string that could be used as an UID.
*/
public const UID_PATTERN = '[a-zA-Z0-9_-]+';
// Events
// -------------------------------------------------------------------------
/**
* @event ConfigEvent The event that is triggered when an item is added to the config.
*
* ---
*
* ```php
* use craft\events\ParseConfigEvent;
* use craft\services\ProjectConfig;
* use yii\base\Event;
*
* Event::on(ProjectConfig::class, ProjectConfig::EVENT_ADD_ITEM, function(ParseConfigEvent $e) {
* // Ensure the item is also added in the database...
* });
* ```
*/
public const EVENT_ADD_ITEM = 'addItem';
/**
* @event ConfigEvent The event that is triggered when an item is updated in the config.
*
* ---
*
* ```php
* use craft\events\ParseConfigEvent;
* use craft\services\ProjectConfig;
* use yii\base\Event;
*
* Event::on(ProjectConfig::class, ProjectConfig::EVENT_UPDATE_ITEM, function(ParseConfigEvent $e) {
* // Ensure the item is also updated in the database...
* });
* ```
*/
public const EVENT_UPDATE_ITEM = 'updateItem';
/**
* @event ConfigEvent The event that is triggered when an item is removed from the config.
*
* ---
*
* ```php
* use craft\events\ParseConfigEvent;
* use craft\services\ProjectConfig;
* use yii\base\Event;
*
* Event::on(ProjectConfig::class, ProjectConfig::EVENT_REMOVE_ITEM, function(ParseConfigEvent $e) {
* // Ensure the item is also removed in the database...
* });
* ```
*/
public const EVENT_REMOVE_ITEM = 'removeItem';
/**
* @event Event The event that is triggered after pending project config file changes have been applied.
*/
public const EVENT_AFTER_APPLY_CHANGES = 'afterApplyChanges';
/**
* @event Event The event that is triggered after the YAML files have been written out.
* @since 4.8.0
*/
public const EVENT_AFTER_WRITE_YAML_FILES = 'afterWriteYamlFiles';
/**
* @event RebuildConfigEvent The event that is triggered when the project config is being rebuilt.
*
* ---
*
* ```php
* use craft\events\RebuildConfigEvent;
* use craft\services\ProjectConfig;
* use yii\base\Event;
*
* Event::on(ProjectConfig::class, ProjectConfig::EVENT_REBUILD, function(RebuildConfigEvent $e) {
* // Add plugin’s project config data...
* $e->config['myPlugin']['key'] = $value;
* });
* ```
*
* @since 3.1.20
*/
public const EVENT_REBUILD = 'rebuild';
/**
* @var bool Whether project config changes should be written to YAML files automatically.
*
* If set to `false`, you can manually write out project config YAML files using the `project-config/write` command.
*
* ::: warning
* If this is set to `false`, Craft won’t have a strong grasp of whether the YAML files or database contain the most relevant
* project config data, so there’s a chance that the Project Config utility will be a bit misleading.
* :::
*
* @see flush()
* @since 3.5.13
*/
public bool $writeYamlAutomatically = true;
/**
* @var string The folder name to save the project config files in, within the `config/` folder.
* @since 3.5.0
*/
public string $folderName = 'project';
/**
* @var int The maximum number of project.yaml deltas to store in storage/config-deltas/
* @since 3.4.0
*/
public int $maxDeltas = 50;
/**
* @var int The maximum number of times deferred events can be re-deferred before we give up on them
* @see defer()
* @see _applyChanges()
*/
public int $maxDefers = 500;
/**
* @var bool Whether the project config is read-only.
*/
public bool $readOnly = false;
/**
* @var bool Whether events generated by config changes should be muted.
* @since 3.1.2
*/
public bool $muteEvents = false;
/**
* @var bool Whether project config should force updates on entries that aren't new or being removed.
*/
public bool $forceUpdate = false;
/**
* @var array A list of all external files.
*/
private array $_configFileList = [];
/**
* @var int|null The project config cache duration. If null, the <config5:cacheDuration> config setting will be used.
* @since 4.5.0
*/
public ?int $cacheDuration = null;
/**
* @var bool Whether to write out updated YAML changes at the end of the request
*/
private bool $_updateYaml = false;
/**
* @var bool Whether we’re listening for the request end, to update the config parse time caches.
* @see updateParsedConfigTimes()
*/
private bool $_waitingToUpdateParsedConfigTimes = false;
/**
* @var bool Whether external project config changes are currently being applied.
* @see getIsApplyingExternalChanges()
*/
private bool $_applyingExternalChanges = false;
/**
* @var bool Whether the config's dateModified timestamp has been updated by this request.
*/
private bool $_timestampUpdated = false;
/**
* @var array Deferred config sync events
* @see defer()
* @see _applyChanges()
*/
private array $_deferredEvents = [];
/**
* A running list of all the changes applied during this request
*
* @var array
*/
private array $_appliedChanges = [];
/**
* @var ReadOnlyProjectConfigData|null Config as defined in the external config.
*/
private ?ReadOnlyProjectConfigData $_externalConfig = null;
/**
* @var ReadOnlyProjectConfigData|null Current config as stored in database.
*/
private ?ReadOnlyProjectConfigData $_internalConfig = null;
/**
* @var ProjectConfigData|null The currently working config - it consists of the current config plus any changes
* applied during this request.
*/
private ?ProjectConfigData $_currentWorkingConfig = null;
/**
* @var array[] Config change handlers
* @see registerChangeEventHandler()
* @see handleChangeEvent()
* @since 3.4.0
*/
private array $_changeEventHandlers = [];
/**
* @var array[] The specificity of change event handlers.
* @see registerChangeEventHandler()
* @see handleChangeEvent()
* @see _sortChangeEventHandlers()
* @since 3.4.0
*/
private array $_changeEventHandlerSpecificity = [];
/**
* @var array[] The registration order of change event handlers.
* @see registerChangeEventHandler()
* @see handleChangeEvent()
* @see _sortChangeEventHandlers()
* @since 3.4.0
*/
private array $_changeEventHandlerRegistrationOrder = [];
/**
* @var bool[] Whether the change event handlers have been sorted.
* @see registerChangeEventHandler()
* @see handleChangeEvent()
* @see _sortChangeEventHandlers()
* @since 3.4.0
*/
private array $_sortedChangeEventHandlers = [];
/**
* @var bool Whether a mutex lock was acquired for this request
* @see _acquireLock()
* @see _releaseLock()
*/
private bool $_locked = false;
/**
* @inheritdoc
*/
public function __construct($config = [])
{
if (isset($config['maxBackups'])) {
$config['maxDeltas'] = ArrayHelper::remove($config, 'maxBackups');
Craft::$app->getDeprecator()->log(self::class . '::maxBackups', '`' . self::class . '::maxBackups` has been deprecated. Use `maxDeltas` instead.');
}
parent::__construct($config);
}
/**
* @inheritdoc
*/
public function init(): void
{
Craft::$app->on(Application::EVENT_AFTER_REQUEST, [$this, 'flush'], append: false);
$this->on(self::EVENT_ADD_ITEM, [$this, 'handleChangeEvent']);
$this->on(self::EVENT_UPDATE_ITEM, [$this, 'handleChangeEvent']);
$this->on(self::EVENT_REMOVE_ITEM, [$this, 'handleChangeEvent']);
parent::init();
}
/**
* Saves the modified project config state and writes out updated YAML files, if needed.
*
* @since 5.0.0
*/
public function flush(): void
{
$this->saveModifiedConfigData();
if ($this->writeYamlAutomatically) {
$this->writeYamlFiles();
}
}
/**
* Resets the internal state.
*
* @internal
*/
public function reset(): void
{
$this->_internalConfig = null;
$this->_externalConfig = null;
$this->_currentWorkingConfig = null;
$this->_configFileList = [];
$this->_updateYaml = false;
$this->_appliedChanges = [];
$this->_applyingExternalChanges = false;
$this->_timestampUpdated = false;
$this->init();
}
/**
* Returns a config item value by its path.
*
* ---
*
* ```php
* $value = Craft::$app->projectConfig->get('foo.bar');
* ```
*
* @param string|null $path The config item path, or `null` if the entire config should be returned
* @param bool $getFromExternalConfig whether data should be fetched from the working config instead of the loaded config. Defaults to `false`.
* @return mixed The config item value
*/
public function get(?string $path = null, bool $getFromExternalConfig = false): mixed
{
if ($getFromExternalConfig) {
$source = $this->getExternalConfig();
} else {
$source = $this->getCurrentWorkingConfig();
}
if ($path === null) {
return $source->export();
}
return $source->get($path);
}
/**
* Finds all config items that pass a condition, and returns their paths and configs as key/value pairs.
*
* @param callable $callback
* @param bool $fromExternalConfig whether to find config items in the external config
* @return array
* @since 5.0.0
*/
public function find(callable $callback, bool $fromExternalConfig = false): array
{
$items = [];
$this->findInternal($this->get(null, $fromExternalConfig), $callback, null, $items);
return $items;
}
private function findInternal(array $config, callable $callback, ?string $path, array &$items): void
{
foreach ($config as $key => $item) {
if (is_array($item)) {
$itemPath = sprintf('%s%s', ($path !== null) ? "$path." : '', $key);
if ($callback($item, $itemPath)) {
$items[$itemPath] = $item;
} else {
$this->findInternal($item, $callback, $itemPath, $items);
}
}
}
}
/**
* Sets a config item value at the given path.
*
* ---
*
* ```php
* Craft::$app->projectConfig->set('foo.bar', 'value');
* ```
*
* @param string $path The config item path
* @param mixed $value The config item value
* @param string|null $message A message describing the changes
* @param bool $updateTimestamp Whether the `dateModified` value should be updated, if it hasn’t been updated yet for this request
* @param bool $force Whether the update should be processed regardless of whether the value actually changed
* @return bool Whether the project config was modified
* @throws ErrorException
* @throws Exception
* @throws NotSupportedException if the service is set to read-only mode
* @throws ServerErrorHttpException
* @throws InvalidConfigException
* @throws BusyResourceException if a lock could not be acquired
* @throws StaleResourceException if the loaded project config is out-of-date
*/
public function set(string $path, mixed $value, ?string $message = null, bool $updateTimestamp = true, bool $force = false): bool
{
if (!$this->_setInternal($path, $value, $message, $updateTimestamp, $force)) {
return false;
}
$this->_saveConfigAfterRequest();
return true;
}
private function _setInternal(string $path, mixed $value, ?string $message = null, bool $updateTimestamp = true, bool $force = false): bool
{
if (is_array($value)) {
$value = ProjectConfigHelper::cleanupConfig($value);
}
$workingConfig = $this->getCurrentWorkingConfig();
$previousValue = $workingConfig->get($path);
$valueHasChanged = $value !== $previousValue;
if (!$valueHasChanged && !$force) {
return false;
}
if ($this->readOnly && $valueHasChanged) {
// If we're applying yaml changes that are coming in via external config, anyway, bail silently.
if ($this->getIsApplyingExternalChanges() && $value === $this->getExternalConfig()->get($path)) {
return true;
}
throw new NotSupportedException('Changes to the project config are not possible while in read-only mode.');
}
if ($updateTimestamp && !$this->_timestampUpdated && $valueHasChanged) {
$this->_timestampUpdated = true;
$this->_setInternal(self::PATH_DATE_MODIFIED, DateTimeHelper::currentTimeStamp(), 'Update timestamp for project config', false, false);
}
if ($valueHasChanged) {
$this->_acquireLock();
}
$this->getCurrentWorkingConfig()->commitChanges($previousValue, $value, $path, $valueHasChanged, $message, true);
return true;
}
/**
* Removes a config item at the given path.
*
* ---
* ```php
* Craft::$app->projectConfig->remove('foo.bar');
* ```
*
* @param string $path The config item path
* @param string|null $message The message describing changes.
*/
public function remove(string $path, ?string $message = null): void
{
$this->set($path, null, $message);
}
/**
* Regenerates the external config based on the loaded project config.
*
* @since 4.0.0
*/
public function regenerateExternalConfig(): void
{
$this->_applyingExternalChanges = false;
// Ensure we have the working config
$this->getCurrentWorkingConfig();
// And ensure we save it.
$this->_saveConfigAfterRequest();
$this->updateParsedConfigTimesAfterRequest();
$this->saveModifiedConfigData();
$this->writeYamlFiles(true);
}
/**
* Applies changes in external config to project config.
*
* @throws BusyResourceException if a lock could not be acquired
* @throws StaleResourceException if the loaded project config is out-of-date
* @since 4.0.0
*/
public function applyExternalChanges(): void
{
$this->_acquireLock();
// Disable read/write splitting for the remainder of this request
Craft::$app->getDb()->enableReplicas = false;
// Start with a clean slate.
$this->reset();
$this->_applyingExternalChanges = true;
$cache = Craft::$app->getCache();
$cache->delete(self::CACHE_KEY);
$changes = $this->_getPendingChanges();
$this->_applyChanges($changes, $this->getCurrentWorkingConfig(), $this->getExternalConfig());
$anyChangesApplied = (bool)(count($changes['newItems']) + count($changes['removedItems']) + count($changes['changedItems']));
// Kill the cached config data
$cache->delete(self::STORED_CACHE_KEY);
if ($anyChangesApplied) {
$this->updateConfigVersion();
}
}
/**
* Applies given changes to the project config.
*
* @param array $configData
*/
public function applyConfigChanges(array $configData): void
{
$this->_applyingExternalChanges = true;
$changes = $this->_getPendingChanges($configData);
$incomingConfig = Craft::createObject(ReadOnlyProjectConfigData::class, [
'data' => $configData,
'projectConfig' => $this,
]);
$this->_applyChanges($changes, $this->getCurrentWorkingConfig(), $incomingConfig);
}
/**
* Returns whether external changes are currently being applied
*
* @return bool
* @since 4.0.0
*/
public function getIsApplyingExternalChanges(): bool
{
return $this->_applyingExternalChanges;
}
/**
* Returns whether external project config files appear to exist.
*
* @return bool
* @since 4.0.0
*/
public function getDoesExternalConfigExist(): bool
{
return file_exists(Craft::$app->getPath()->getProjectConfigFilePath());
}
/**
* Returns whether a given path has pending changes that need to be applied to the loaded project config.
*
* @param string|null $path A specific config path that should be checked for pending changes.
* If this is null, then `true` will be returned if there are *any* pending changes in external config.
* @param bool $force Whether to check for changes even if it doesn’t look like anything has changed since
* the last time [[ignorePendingChanges()]] has been called.
* @return bool
*/
public function areChangesPending(?string $path = null, bool $force = false): bool
{
// If the path is currently being processed, return true
if ($path !== null && $this->getCurrentWorkingConfig()->getHasPathBeenModified($path)) {
return true;
}
// If the file does not exist, but should, generate it
if ($this->getHadFileWriteIssues() || !$this->getDoesExternalConfigExist()) {
if ($this->writeYamlAutomatically) {
$this->regenerateExternalConfig();
} else {
$this->saveModifiedConfigData();
}
return false;
}
if (!$force) {
// If the file modification date hasn't changed, then no need to check the contents
$cachedModifiedTime = Craft::$app->getCache()->get(self::CACHE_KEY);
if (
$cachedModifiedTime &&
$cachedModifiedTime === $this->_getConfigFileModifiedTime()
) {
return false;
}
}
if ($path !== null) {
$oldValue = $this->getInternalConfig()->get($path);
$newValue = $this->getExternalConfig()->get($path);
return ProjectConfigHelper::encodeValueAsString($oldValue) !== ProjectConfigHelper::encodeValueAsString($newValue);
}
// If the file contents haven't changed, just update the cached file modification date
if (!$this->_getPendingChanges(null, true)) {
$this->updateParsedConfigTimes();
return false;
}
// Clear the cached config, just in case it conflicts with what we've got here
Craft::$app->getCache()->delete(self::STORED_CACHE_KEY);
$this->_currentWorkingConfig = null;
return true;
}
/**
* Processes changes in the project config files for a given config item path.
*
* Note that this will only have an effect if external project config changes are currently getting [[getIsApplyingExternalChanges()|applied]].
*
* @param string $path The config item path
* @param bool $force Whether the config change should be processed regardless of previous records,
* or whether external changes are currently being applied
*/
public function processConfigChanges(string $path, bool $force = false): void
{
if ($force || $this->getIsApplyingExternalChanges()) {
$this->getCurrentWorkingConfig()->commitChanges($this->getInternalConfig()->get($path), $this->getExternalConfig()->get($path), $path, false, null, $force);
}
}
/**
* Updates cached config file modified times after the request ends.
*/
public function updateParsedConfigTimesAfterRequest(): void
{
if ($this->_waitingToUpdateParsedConfigTimes) {
return;
}
$this->_waitingToUpdateParsedConfigTimes = true;
Craft::$app->onAfterRequest(function() {
$this->updateParsedConfigTimes();
});
}
/**
* Ignores any pending changes in the project config files.
*
* @since 3.5.0
* @deprecated in 5.6.6
*/
public function ignorePendingChanges(): void
{
}
/**
* Updates cached config file modified times immediately.
*
* @return bool
*/
public function updateParsedConfigTimes(): bool
{
return Craft::$app->getCache()->set(
self::CACHE_KEY,
$this->_getConfigFileModifiedTime(),
self::CACHE_DURATION,
);
}
/**
* Saves all the config data that has been modified up to now.
*
* @throws ErrorException
*/
public function saveModifiedConfigData(): void
{
if (!empty($this->_appliedChanges)) {
$deltaChanges = [];
$db = Craft::$app->getDb();
$db->transaction(function() use ($db, &$deltaChanges) {
foreach ($this->_appliedChanges as $changeSet) {
// Allow modification of the array being looped over.
$currentSet = $changeSet;
if (!empty($changeSet['removed'])) {
$this->removeInternalConfigValuesByPaths(array_keys($changeSet['removed']));
}
if (!empty($changeSet['added'])) {
$isMysql = $db->getIsMysql();
$batch = [];
$pathsToInsert = [];
$additionalCleanupPaths = [];
foreach ($currentSet['added'] as $key => $value) {
// Prepare for storage
$dbValue = ProjectConfigHelper::encodeValueAsString($value);
if (!mb_check_encoding($dbValue, 'UTF-8') || ($isMysql && StringHelper::containsMb4($dbValue))) {
$dbValue = 'base64:' . base64_encode($dbValue);
}
$batch[$key] = $dbValue;
$pathsToInsert[] = $key;
// Delete parent key, as it cannot hold a value AND be an array at the same time
$additionalCleanupPaths[ProjectConfigHelper::pathWithoutLastSegment($key) ?? $key] = true;
// Prepare for delta
if (!empty($currentSet['removed']) && array_key_exists($key, $currentSet['removed'])) {
if (is_string($changeSet['removed'][$key])) {
$changeSet['removed'][$key] = StringHelper::decdec($changeSet['removed'][$key]);
}
$changeSet['removed'][$key] = Json::decodeIfJson($changeSet['removed'][$key]);
// Ensure types
if (is_bool($value)) {
$changeSet['removed'][$key] = (bool)$changeSet['removed'][$key];
} elseif (is_int($value)) {
$changeSet['removed'][$key] = (int)$changeSet['removed'][$key];
}
if ($changeSet['removed'][$key] === $value) {
unset($changeSet['removed'][$key], $changeSet['added'][$key]);
} elseif (array_key_exists($key, $changeSet['removed'])) {
$changeSet['changed'][$key] = [
'from' => $changeSet['removed'][$key],
'to' => $changeSet['added'][$key],
];
unset($changeSet['removed'][$key], $changeSet['added'][$key]);
}
}
}
// Store in the DB
if (!empty($batch)) {
$this->removeInternalConfigValuesByPaths($pathsToInsert);
$this->removeInternalConfigValuesByPaths(array_keys($additionalCleanupPaths));
$this->persistInternalConfigValues($batch);
}
}
$changeSet = array_filter($changeSet);
if (!empty($changeSet)) {
$deltaChanges[] = $changeSet;
}
}
$this->updateConfigVersion();
$this->_releaseLock();
});
if (!empty($deltaChanges)) {
$this->storeYamlHistory([
'dateApplied' => date('Y-m-d H:i:s'),
'changes' => $deltaChanges,
]);
}
}
}
/**
* Remove values from internal config by a list of paths.
*
* @param array $paths
*/
protected function removeInternalConfigValuesByPaths(array $paths): void
{
$chunks = array_chunk($paths, 1000);
foreach ($chunks as $chunk) {
Db::delete(Table::PROJECTCONFIG, [
'path' => $chunk,
]);
}
}
/**
* Persist an array of `$path => $value` to the internal config.
*
* @param array $values
* @throws \yii\db\Exception
*/
protected function persistInternalConfigValues(array $values): void
{
$batch = [];
foreach ($values as $path => $value) {
$batch[] = [$path, $value];
}
Db::batchInsert(Table::PROJECTCONFIG, ['path', 'value'], $batch);
}
/**
* Returns a summary of all pending config changes.
*
* @return array
* @deprecated in 5.9.19
*/
public function getPendingChangeSummary(): array
{
$pendingChanges = $this->_getPendingChanges();
$summary = [];
// Reduce all the small changes to overall item changes.
foreach ($pendingChanges as $type => $changes) {
$summary[$type] = [];
foreach ($changes as $path) {
$pathParts = ProjectConfigHelper::pathSegments($path);
if (count($pathParts) > 1) {
$summary[$type][$pathParts[0] . '.' . $pathParts[1]] = true;
}
}
}
return $summary;
}
/**
* Get the list of applied changes
*
* @return array
* @since 5.1.0
*/
public function getAppliedChanges(): array
{
return $this->_appliedChanges;
}
/**
* Returns whether all schema versions stored in the config are compatible with the actual codebase.
* The schemas must match exactly to avoid unpredictable behavior that can occur when running migrations
* and applying project config changes at the same time.
*
* @param array $issues Passed by reference and populated with issues on error in
* the following format: `[$pluginName, $existingSchema, $incomingSchema]`
* @return bool
*/
public function getAreConfigSchemaVersionsCompatible(array &$issues = []): bool
{
$incomingSchema = (string)$this->getExternalConfig()->get(self::PATH_SCHEMA_VERSION);
$existingSchema = Craft::$app->schemaVersion;
// Compare existing Craft schema version with the one that is being applied.
if (!version_compare($existingSchema, $incomingSchema, '=')) {
$issues[] = [
'cause' => 'Craft CMS',
'existing' => $existingSchema,
'incoming' => $incomingSchema,
];
}
$plugins = Craft::$app->getPlugins()->getAllPlugins();
foreach ($plugins as $plugin) {
$incomingSchema = (string)$this->getExternalConfig()->get(self::PATH_PLUGINS . '.' . $plugin->handle . '.schemaVersion');
$existingSchema = $plugin->schemaVersion;
// Compare existing plugin schema version with the one that is being applied.
if ($incomingSchema && !version_compare($existingSchema, $incomingSchema, '=')) {
$issues[] = [
'cause' => $plugin->name,
'existing' => $existingSchema,
'incoming' => $incomingSchema,
];
}
}
return empty($issues);
}
// Config Change Event Registration
// -------------------------------------------------------------------------
/**
* Attaches an event handler for when an item is added to the config at a given path.