Completed
Push — 7.5 ( 70cc97...2f9a1f )
by Łukasz
21:35
created

ConfigResolver::warnAboutTooEarlyLoadedParams()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
nc 3
nop 0
dl 0
loc 21
rs 9.584
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * File containing the ConfigResolver class.
5
 *
6
 * @copyright Copyright (C) eZ Systems AS. All rights reserved.
7
 * @license For full copyright and license information view LICENSE file distributed with this source code.
8
 */
9
namespace eZ\Bundle\EzPublishCoreBundle\DependencyInjection\Configuration;
10
11
use eZ\Publish\Core\MVC\Symfony\Configuration\VersatileScopeInterface;
12
use eZ\Publish\Core\MVC\Symfony\SiteAccess;
13
use eZ\Publish\Core\MVC\Symfony\SiteAccess\SiteAccessAware;
14
use eZ\Publish\Core\MVC\Exception\ParameterNotFoundException;
15
use Symfony\Component\DependencyInjection\ContainerAwareInterface;
16
use Symfony\Component\DependencyInjection\ContainerAwareTrait;
17
use Symfony\Component\DependencyInjection\ContainerBuilder;
18
19
/**
20
 * This class will help you get settings for a specific scope.
21
 * This is useful to get a setting for a specific siteaccess for example.
22
 *
23
 * It will check the different scopes available for a given namespace to find the appropriate parameter.
24
 * To work, the dynamic setting must comply internally to the following name format : "<namespace>.<scope>.parameter.name".
25
 *
26
 * - <namespace> is the namespace for your dynamic setting. Defaults to "ezsettings", but can be anything.
27
 * - <scope> is basically the siteaccess name you want your parameter value to apply to.
28
 *   Can also be "global" for a global override.
29
 *   Another scope is used internally: "default". This is the generic fallback.
30
 *
31
 * The resolve scope order is the following:
32
 * 1. "global"
33
 * 2. SiteAccess name
34
 * 3. "default"
35
 */
36
class ConfigResolver implements VersatileScopeInterface, SiteAccessAware, ContainerAwareInterface
37
{
38
    use ContainerAwareTrait;
39
40
    const SCOPE_GLOBAL = 'global';
41
    const SCOPE_DEFAULT = 'default';
42
43
    const UNDEFINED_STRATEGY_EXCEPTION = 1;
44
    const UNDEFINED_STRATEGY_NULL = 2;
45
46
    /** @var \eZ\Publish\Core\MVC\Symfony\SiteAccess */
47
    protected $siteAccess;
48
49
    /** @var array Siteaccess groups, indexed by siteaccess name */
50
    protected $groupsBySiteAccess;
51
52
    /** @var string */
53
    protected $defaultNamespace;
54
55
    /** @var string */
56
    protected $defaultScope;
57
58
    /** @var int */
59
    protected $undefinedStrategy;
60
61
    /** @var array[] List of blame => [params] loaded while siteAccess->matchingType was 'uninitialized' */
62
    private $tooEarlyLoadedList = [];
63
64
    /**
65
     * @param array $groupsBySiteAccess SiteAccess groups, indexed by siteaccess.
66
     * @param string $defaultNamespace The default namespace
67
     * @param int $undefinedStrategy Strategy to use when encountering undefined parameters.
68
     *                               Must be one of
69
     *                                  - ConfigResolver::UNDEFINED_STRATEGY_EXCEPTION (throw an exception)
70
     *                                  - ConfigResolver::UNDEFINED_STRATEGY_NULL (return null)
71
     */
72
    public function __construct(
73
        array $groupsBySiteAccess,
74
        $defaultNamespace,
75
        $undefinedStrategy = self::UNDEFINED_STRATEGY_EXCEPTION
76
    ) {
77
        $this->groupsBySiteAccess = $groupsBySiteAccess;
78
        $this->defaultNamespace = $defaultNamespace;
79
        $this->undefinedStrategy = $undefinedStrategy;
80
    }
81
82
    public function setSiteAccess(SiteAccess $siteAccess = null)
83
    {
84
        $this->siteAccess = $siteAccess;
85
    }
86
87
    /**
88
     * Sets the strategy to use if an undefined parameter is being asked.
89
     * Can be one of:
90
     *  - ConfigResolver::UNDEFINED_STRATEGY_EXCEPTION (throw an exception)
91
     *  - ConfigResolver::UNDEFINED_STRATEGY_NULL (return null).
92
     *
93
     * Defaults to ConfigResolver::UNDEFINED_STRATEGY_EXCEPTION.
94
     *
95
     * @param int $undefinedStrategy
96
     */
97
    public function setUndefinedStrategy($undefinedStrategy)
98
    {
99
        $this->undefinedStrategy = $undefinedStrategy;
100
    }
101
102
    /**
103
     * @return int
104
     */
105
    public function getUndefinedStrategy()
106
    {
107
        return $this->undefinedStrategy;
108
    }
109
110
    /**
111
     * Checks if $paramName exists in $namespace.
112
     *
113
     * @param string $paramName
114
     * @param string $namespace If null, the default namespace should be used.
115
     * @param string $scope The scope you need $paramName value for. It's typically the siteaccess name.
116
     *                      If null, the current siteaccess name will be used.
117
     *
118
     * @return bool
119
     */
120
    public function hasParameter($paramName, $namespace = null, $scope = null)
121
    {
122
        $namespace = $namespace ?: $this->defaultNamespace;
123
        $scope = $scope ?: $this->getDefaultScope();
124
125
        $defaultScopeParamName = "$namespace." . self::SCOPE_DEFAULT . ".$paramName";
126
        $globalScopeParamName = "$namespace." . self::SCOPE_GLOBAL . ".$paramName";
127
        $relativeScopeParamName = "$namespace.$scope.$paramName";
128
129
        // Relative scope, siteaccess group wise
130
        $groupScopeHasParam = false;
131
        if (isset($this->groupsBySiteAccess[$scope])) {
132 View Code Duplication
            foreach ($this->groupsBySiteAccess[$scope] as $groupName) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
133
                $groupScopeParamName = "$namespace.$groupName.$paramName";
134
                if ($this->container->hasParameter($groupScopeParamName)) {
135
                    $groupScopeHasParam = true;
136
                    break;
137
                }
138
            }
139
        }
140
141
        return
142
            $this->container->hasParameter($defaultScopeParamName)
143
            || $groupScopeHasParam
144
            || $this->container->hasParameter($relativeScopeParamName)
145
            || $this->container->hasParameter($globalScopeParamName);
146
    }
147
148
    /**
149
     * Returns value for $paramName, in $namespace.
150
     *
151
     * @param string $paramName The parameter name, without $prefix and the current scope (i.e. siteaccess name).
152
     * @param string $namespace Namespace for the parameter name. If null, the default namespace will be used.
153
     * @param string $scope The scope you need $paramName value for. It's typically the siteaccess name.
154
     *                      If null, the current siteaccess name will be used.
155
     *
156
     * @throws \eZ\Publish\Core\MVC\Exception\ParameterNotFoundException
157
     *
158
     * @return mixed
159
     */
160
    public function getParameter($paramName, $namespace = null, $scope = null)
161
    {
162
        $this->logTooEarlyLoadedListIfNeeded($paramName);
163
164
        $namespace = $namespace ?: $this->defaultNamespace;
165
        $scope = $scope ?: $this->getDefaultScope();
166
        $triedScopes = [];
167
168
        // Global scope
169
        $globalScopeParamName = "$namespace." . self::SCOPE_GLOBAL . ".$paramName";
170
        if ($this->container->hasParameter($globalScopeParamName)) {
171
            return $this->container->getParameter($globalScopeParamName);
172
        }
173
        $triedScopes[] = self::SCOPE_GLOBAL;
174
        unset($globalScopeParamName);
175
176
        // Relative scope, siteaccess wise
177
        $relativeScopeParamName = "$namespace.$scope.$paramName";
178
        if ($this->container->hasParameter($relativeScopeParamName)) {
179
            return $this->container->getParameter($relativeScopeParamName);
180
        }
181
        $triedScopes[] = $scope;
182
        unset($relativeScopeParamName);
183
184
        // Relative scope, siteaccess group wise
185
        if (isset($this->groupsBySiteAccess[$scope])) {
186 View Code Duplication
            foreach ($this->groupsBySiteAccess[$scope] as $groupName) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
187
                $relativeScopeParamName = "$namespace.$groupName.$paramName";
188
                if ($this->container->hasParameter($relativeScopeParamName)) {
189
                    return $this->container->getParameter($relativeScopeParamName);
190
                }
191
            }
192
        }
193
194
        // Default scope
195
        $defaultScopeParamName = "$namespace." . self::SCOPE_DEFAULT . ".$paramName";
196
        if ($this->container->hasParameter($defaultScopeParamName)) {
197
            return $this->container->getParameter($defaultScopeParamName);
198
        }
199
        $triedScopes[] = $this->defaultNamespace;
200
        unset($defaultScopeParamName);
201
202
        // Undefined parameter
203
        switch ($this->undefinedStrategy) {
204
            case self::UNDEFINED_STRATEGY_NULL:
205
                return null;
206
207
            case self::UNDEFINED_STRATEGY_EXCEPTION:
208
            default:
209
                throw new ParameterNotFoundException($paramName, $namespace, $triedScopes);
210
        }
211
    }
212
213
    /**
214
     * Changes the default namespace to look parameter into.
215
     *
216
     * @param string $defaultNamespace
217
     */
218
    public function setDefaultNamespace($defaultNamespace)
219
    {
220
        $this->defaultNamespace = $defaultNamespace;
221
    }
222
223
    /**
224
     * @return string
225
     */
226
    public function getDefaultNamespace()
227
    {
228
        return $this->defaultNamespace;
229
    }
230
231
    public function getDefaultScope()
232
    {
233
        return $this->defaultScope ?: $this->siteAccess->name;
234
    }
235
236
    /**
237
     * @param string $scope The default "scope" aka siteaccess name, as opposed to the self::SCOPE_DEFAULT.
238
     */
239
    public function setDefaultScope($scope)
240
    {
241
        $this->defaultScope = $scope;
242
243
        // On scope change check if siteaccess has been updated so we can log warnings if there are any
244
        if ($this->siteAccess->matchingType !== 'uninitialized') {
245
            $this->warnAboutTooEarlyLoadedParams();
246
        }
247
    }
248
249
    private function warnAboutTooEarlyLoadedParams()
250
    {
251
        if (empty($this->tooEarlyLoadedList)) {
252
            return;
253
        }
254
255
        $logger = $this->container->get('logger');
256
        foreach ($this->tooEarlyLoadedList as $blame => $params) {
257
            $logger->warning(sprintf(
258
                'ConfigResolver was used by "%s" before SiteAccess was initialized, loading parameter(s) '
259
                . '%s. As this can cause very hard to debug issues, '
260
                . 'try to use ConfigResolver lazily, '
261
                . (PHP_SAPI === 'cli' ? 'make the affected commands lazy, ' : '')
262
                . 'make the service lazy or see if you can inject another lazy service.',
263
                $blame,
264
                '"$' . implode('$", "$', array_unique($params)) . '$"'
265
            ));
266
        }
267
268
        $this->tooEarlyLoadedList = [];
269
    }
270
271
    /**
272
     * If in run-time debug mode, before SiteAccess is initialized, log getParameter() usages when considered "unsafe".
273
     *
274
     * @return string
275
     */
276
    private function logTooEarlyLoadedListIfNeeded($paramName)
277
    {
278
        if ($this->container instanceof ContainerBuilder) {
279
            return;
280
        }
281
282
        if ($this->siteAccess->matchingType !== 'uninitialized') {
283
            return;
284
        }
285
286
        // So we are in a state where we need to warn about unsafe use of config resolver parameters...
287
        // .. it's a bit costly to do so, so we only do it in debug mode
288
        $container = $this->container;
289
        if (!$container->getParameter('kernel.debug')) {
290
            return;
291
        }
292
293
        $serviceName = '??';
294
        $firstService = '??';
295
        $commandName = null;
296
        $resettableServiceIds = $container->getParameter('ezpublish.config_resolver.resettable_services');
297
        $updatableServices = $container->getParameter('ezpublish.config_resolver.updateable_services');
298
299
        // Lookup trace to find last service being loaded as possible blame for eager loading
300
        // Abort if one of the earlier services is detected to be "safe", aka updatable
301
        foreach (debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 35) as $t) {
302
            if (!isset($t['function']) || $t['function'] === 'getParameter' || $t['function'] === __FUNCTION__) {
303
                continue;
304
            }
305
306
            // Extract service name from first service matching getXXService pattern
307
            if (\strpos($t['function'], 'get') === 0 && \strrpos($t['function'], 'Service') === \strlen($t['function']) - 7) {
308
                $potentialClassName = \substr($t['function'], 3, -7);
309
                $serviceName = \strtolower(\preg_replace('/\B([A-Z])/', '_$1', \str_replace('_', '.', $potentialClassName)));
310
311
                // This (->setter('$dynamic_param$')) is safe as the system is able to update it on scope changes, abort
312
                if (isset($updatableServices[$serviceName])) {
313
                    return;
314
                }
315
316
                // !! The remaining cases are most likely "not safe", typically:
317
                // - ctor('$dynamic_param$') => this should be avoided, use setter or use config resolver instead
318
                // - config resolver use in service factory => the service (or decorator, if any) should be marked lazy
319
320
                // Possible exception: Class name based services, can't be resolved as namespace is omitted from
321
                // compiled function. In this case we won't know if it was updateable and "safe", so we warn to be sure
322
                if (!in_array($serviceName, $resettableServiceIds, true) && !$container->has($serviceName)) {
323
                    $serviceName = $potentialClassName;
324
                } else {
325
                    $serviceName = '@' . $serviceName;
326
                }
327
328
                // Keep track of the first service loaded
329
                if ($firstService === '??') {
330
                    $firstService = $serviceName;
331
                }
332
333
                // Detect if we found the command loading the service, if we track that as lasts service
334
                if (PHP_SAPI === 'cli' && isset($t['file']) && \stripos($t['file'], 'CommandService.php') !== false) {
335
                    $path = explode(DIRECTORY_SEPARATOR, $t['file']);
336
                    $commandName = \substr($path[count($path) - 1], 3, -11);
337
                    break;
338
                }
339
            }
340
        }
341
342
        // Skip service name if same as first service
343
        if ($serviceName === $firstService) {
344
            $serviceName = '';
345
        }
346
347
        // Add command name if present as the trigger
348
        if ($commandName) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $commandName of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
349
            $blame = "$commandName($serviceName) -> $firstService";
350
        } else {
351
            $blame = ($serviceName ? $serviceName . ' -> ' : '') . $firstService;
352
        }
353
        $this->tooEarlyLoadedList[$blame][] = $paramName;
354
    }
355
}
356