lthn.io/app/Core/Config/ConfigResolver.php

640 lines
20 KiB
PHP
Raw Permalink Normal View History

<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Config;
use Core\Config\Contracts\ConfigProvider;
use Core\Config\Enums\ScopeType;
use Core\Config\Models\Channel;
use Core\Config\Models\ConfigKey;
use Core\Config\Models\ConfigProfile;
use Core\Config\Models\ConfigValue;
use Illuminate\Support\Collection;
/**
* Configuration resolution engine.
*
* Single static hash for all config values:
* - Runtime values from modules
* - Resolved values from database
* - All in one place, zero-DB reads after warmup
*
* Read path: $values[$key] ?? compute and store
*
* Resolution dimensions (when computing):
* - Scope: workspace org system (most specific wins)
* - Channel: specific parent null (most specific wins)
*
* Respects FINAL/locked declarations from parent scopes.
*/
class ConfigResolver
{
/**
* The hash. Key value. That's it.
*
* @var array<string, mixed>
*/
protected static array $values = [];
/**
* Whether the hash has been loaded.
*/
protected static bool $loaded = false;
/**
* Registered virtual providers.
*
* Supports both ConfigProvider instances and callable functions.
*
* @var array<string, ConfigProvider|callable>
*/
protected array $providers = [];
// =========================================================================
// THE HASH
// =========================================================================
/**
* Get a value from the hash.
*/
public static function get(string $key): mixed
{
return static::$values[$key] ?? null;
}
/**
* Set a value in the hash.
*/
public static function set(string $key, mixed $value): void
{
static::$values[$key] = $value;
}
/**
* Check if a value exists in the hash.
*/
public static function has(string $key): bool
{
return array_key_exists($key, static::$values);
}
/**
* Clear keys matching a pattern (bi-directional).
*/
public static function clear(string $pattern): void
{
static::$values = array_filter(
static::$values,
fn ($k) => ! str_contains($k, $pattern),
ARRAY_FILTER_USE_KEY
);
}
/**
* Clear entire hash.
*/
public static function clearAll(): void
{
static::$values = [];
static::$loaded = false;
}
/**
* Get all values (for debugging).
*
* @return array<string, mixed>
*/
public static function all(): array
{
return static::$values;
}
/**
* Check if hash has been loaded.
*/
public static function isLoaded(): bool
{
return static::$loaded;
}
/**
* Mark hash as loaded.
*/
public static function markLoaded(): void
{
static::$loaded = true;
}
// =========================================================================
// RESOLUTION ENGINE (only runs during lazy prime, not normal reads)
// =========================================================================
/**
* Resolve a single key for a workspace and optional channel.
*
* NOTE: This is the expensive path - only called when lazy-priming.
* Normal reads hit the hash directly via ConfigService.
*
* @param object|null $workspace Workspace model instance or null for system scope
* @param string|Channel|null $channel Channel code or object
*/
public function resolve(
string $keyCode,
?object $workspace = null,
string|Channel|null $channel = null,
): ConfigResult {
// Get key definition (DB query - only during resolve, not normal reads)
$key = ConfigKey::byCode($keyCode);
if ($key === null) {
// Try JSON sub-key extraction
return $this->resolveJsonSubKey($keyCode, $workspace, $channel);
}
// Build chains
$profileChain = $this->buildProfileChain($workspace);
$channelChain = $this->buildChannelChain($channel, $workspace);
// Batch load all values for this key
$values = $this->batchLoadValues(
$key->id,
$profileChain->pluck('id')->all(),
$channelChain->pluck('id')->all()
);
// Build resolution matrix (profile × channel combinations)
$matrix = $this->buildResolutionMatrix($profileChain, $channelChain);
// First pass: check for FINAL locks (from least specific scope)
$lockedResult = $this->findFinalLock($matrix, $values, $keyCode, $key);
if ($lockedResult !== null) {
return $lockedResult;
}
// Second pass: find most specific value
foreach ($matrix as $combo) {
$value = $this->findValueInBatch($values, $combo['profile_id'], $combo['channel_id']);
if ($value !== null) {
return ConfigResult::found(
key: $keyCode,
value: $value->value,
type: $key->type,
locked: false,
resolvedFrom: $combo['scope_type'],
profileId: $combo['profile_id'],
channelId: $combo['channel_id'],
);
}
}
// Check virtual providers
$virtualValue = $this->resolveFromProviders($keyCode, $workspace, $channel);
if ($virtualValue !== null) {
return ConfigResult::virtual(
key: $keyCode,
value: $virtualValue,
type: $key->type,
);
}
// No value found - return default
return ConfigResult::notFound($keyCode, $key->getTypedDefault(), $key->type);
}
/**
* Maximum recursion depth for JSON sub-key resolution.
*/
protected const MAX_SUBKEY_DEPTH = 10;
/**
* Current recursion depth for sub-key resolution.
*/
protected int $subKeyDepth = 0;
/**
* Try to resolve a JSON sub-key (e.g., "website.title" from "website" JSON).
*/
/**
* @param object|null $workspace Workspace model instance or null for system scope
*/
protected function resolveJsonSubKey(
string $keyCode,
?object $workspace,
string|Channel|null $channel,
): ConfigResult {
// Guard against stack overflow from deep nesting
if ($this->subKeyDepth >= self::MAX_SUBKEY_DEPTH) {
return ConfigResult::unconfigured($keyCode);
}
$this->subKeyDepth++;
try {
$parts = explode('.', $keyCode);
// Try progressively shorter parent keys
for ($i = count($parts) - 1; $i > 0; $i--) {
$parentKey = implode('.', array_slice($parts, 0, $i));
$subPath = implode('.', array_slice($parts, $i));
$parentResult = $this->resolve($parentKey, $workspace, $channel);
if ($parentResult->found && is_array($parentResult->value)) {
$subValue = data_get($parentResult->value, $subPath);
if ($subValue !== null) {
return ConfigResult::found(
key: $keyCode,
value: $subValue,
type: $parentResult->type, // Inherit parent type
locked: $parentResult->locked,
resolvedFrom: $parentResult->resolvedFrom,
profileId: $parentResult->profileId,
channelId: $parentResult->channelId,
);
}
}
}
return ConfigResult::unconfigured($keyCode);
} finally {
$this->subKeyDepth--;
}
}
/**
* Build the channel inheritance chain.
*
* @param object|null $workspace Workspace model instance or null for system scope
* @return Collection<int, Channel|null>
*/
public function buildChannelChain(
string|Channel|null $channel,
?object $workspace = null,
): Collection {
$chain = new Collection;
if ($channel === null) {
// No channel specified - just null (applies to all)
$chain->push(null);
return $chain;
}
// Resolve channel code to model
if (is_string($channel)) {
$channel = Channel::byCode($channel, $workspace?->id);
}
if ($channel !== null) {
// Add channel inheritance chain
$chain = $chain->merge($channel->inheritanceChain());
}
// Always include null (all-channels fallback)
$chain->push(null);
return $chain;
}
/**
* Batch load all values for a key across profiles and channels.
*
* @param array<int> $profileIds
* @param array<int|null> $channelIds
* @return Collection<int, ConfigValue>
*/
protected function batchLoadValues(int $keyId, array $profileIds, array $channelIds): Collection
{
// Separate null from actual channel IDs for query
$actualChannelIds = array_filter($channelIds, fn ($id) => $id !== null);
return ConfigValue::where('key_id', $keyId)
->whereIn('profile_id', $profileIds)
->where(function ($query) use ($actualChannelIds) {
$query->whereNull('channel_id');
if (! empty($actualChannelIds)) {
$query->orWhereIn('channel_id', $actualChannelIds);
}
})
->get();
}
/**
* Build resolution matrix (profile × channel combinations).
*
* Order: most specific first (workspace + specific channel)
* to least specific (system + null channel).
*
* @return array<array{profile_id: int, channel_id: int|null, scope_type: ScopeType}>
*/
protected function buildResolutionMatrix(Collection $profileChain, Collection $channelChain): array
{
$matrix = [];
foreach ($profileChain as $profile) {
foreach ($channelChain as $channel) {
$matrix[] = [
'profile_id' => $profile->id,
'channel_id' => $channel?->id,
'scope_type' => $profile->scope_type,
];
}
}
return $matrix;
}
/**
* Find a FINAL lock in the resolution matrix.
*
* Checks from least specific (system) to find any lock that
* would prevent more specific values from being used.
*/
protected function findFinalLock(
array $matrix,
Collection $values,
string $keyCode,
ConfigKey $key,
): ?ConfigResult {
// Reverse to check from least specific (system)
$reversed = array_reverse($matrix);
foreach ($reversed as $combo) {
$value = $this->findValueInBatch($values, $combo['profile_id'], $combo['channel_id']);
if ($value !== null && $value->isLocked()) {
return ConfigResult::found(
key: $keyCode,
value: $value->value,
type: $key->type,
locked: true,
resolvedFrom: $combo['scope_type'],
profileId: $combo['profile_id'],
channelId: $combo['channel_id'],
);
}
}
return null;
}
/**
* Find a value in the batch-loaded collection.
*/
protected function findValueInBatch(Collection $values, int $profileId, ?int $channelId): ?ConfigValue
{
return $values->first(function (ConfigValue $value) use ($profileId, $channelId) {
return $value->profile_id === $profileId
&& $value->channel_id === $channelId;
});
}
/**
* Register a virtual provider for a key pattern.
*
* Providers supply values from module data without database storage.
* Accepts either a ConfigProvider instance or a callable.
*
* @param string|ConfigProvider $patternOrProvider Key pattern (supports * wildcard) or ConfigProvider instance
* @param ConfigProvider|callable|null $provider ConfigProvider instance or fn(string $key, ?object $workspace, ?Channel $channel): mixed
*/
public function registerProvider(string|ConfigProvider $patternOrProvider, ConfigProvider|callable|null $provider = null): void
{
// Support both new interface-based and legacy callable patterns
if ($patternOrProvider instanceof ConfigProvider) {
$this->providers[$patternOrProvider->pattern()] = $patternOrProvider;
} elseif ($provider !== null) {
$this->providers[$patternOrProvider] = $provider;
}
}
/**
* Resolve value from virtual providers.
*
* Supports both ConfigProvider instances and legacy callables.
*
* @param object|null $workspace Workspace model instance or null for system scope
*/
public function resolveFromProviders(
string $keyCode,
?object $workspace,
string|Channel|null $channel,
): mixed {
foreach ($this->providers as $pattern => $provider) {
if ($this->matchesPattern($keyCode, $pattern)) {
// Support both ConfigProvider interface and legacy callable
$value = $provider instanceof ConfigProvider
? $provider->resolve($keyCode, $workspace, $channel)
: $provider($keyCode, $workspace, $channel);
if ($value !== null) {
return $value;
}
}
}
return null;
}
/**
* Check if a key matches a provider pattern.
*/
protected function matchesPattern(string $key, string $pattern): bool
{
if ($pattern === $key) {
return true;
}
// Convert pattern to regex (e.g., "bio.*" → "^bio\..*$")
$regex = '/^'.str_replace(['.', '*'], ['\.', '.*'], $pattern).'$/';
return (bool) preg_match($regex, $key);
}
/**
* Resolve all keys for a workspace.
*
* NOTE: Only called during prime, not normal reads.
*
* @param object|null $workspace Workspace model instance or null for system scope
* @return array<string, ConfigResult>
*/
public function resolveAll(?object $workspace = null, string|Channel|null $channel = null): array
{
$results = [];
// Query all keys from DB (only during prime)
foreach (ConfigKey::all() as $key) {
$results[$key->code] = $this->resolve($key->code, $workspace, $channel);
}
return $results;
}
/**
* Resolve all keys in a category.
*
* NOTE: Only called during prime, not normal reads.
*
* @param object|null $workspace Workspace model instance or null for system scope
* @return array<string, ConfigResult>
*/
public function resolveCategory(
string $category,
?object $workspace = null,
string|Channel|null $channel = null,
): array {
$results = [];
// Query keys by category from DB (only during prime)
foreach (ConfigKey::where('category', $category)->get() as $key) {
$results[$key->code] = $this->resolve($key->code, $workspace, $channel);
}
return $results;
}
/**
* Build the profile chain for resolution.
*
* Returns profiles ordered from most specific (workspace) to least (system).
* Chain: workspace org system
*
* @param object|null $workspace Workspace model instance or null for system scope
* @return Collection<int, ConfigProfile>
*/
public function buildProfileChain(?object $workspace = null): Collection
{
$chain = new Collection;
// Workspace profiles (most specific)
if ($workspace !== null) {
$workspaceProfiles = ConfigProfile::forScope(ScopeType::WORKSPACE, $workspace->id);
$chain = $chain->merge($workspaceProfiles);
// Org layer - workspace belongs to organisation
$orgId = $this->resolveOrgId($workspace);
if ($orgId !== null) {
$orgProfiles = ConfigProfile::forScope(ScopeType::ORG, $orgId);
$chain = $chain->merge($orgProfiles);
}
}
// System profiles (least specific)
$systemProfiles = ConfigProfile::forScope(ScopeType::SYSTEM, null);
$chain = $chain->merge($systemProfiles);
// Add parent profile inheritance
$chain = $this->expandParentProfiles($chain);
return $chain;
}
/**
* Resolve organisation ID from workspace.
*
* Stub for now - will connect to Tenant module when org model exists.
* Organisation = multi-workspace grouping (agency accounts, teams).
*
* @param object|null $workspace Workspace model instance or null
*/
protected function resolveOrgId(?object $workspace): ?int
{
if ($workspace === null) {
return null;
}
// Workspace::organisation_id when model has org support
// For now, return null (no org layer)
return $workspace->organisation_id ?? null;
}
/**
* Expand chain to include parent profiles.
*
* @param Collection<int, ConfigProfile> $chain
* @return Collection<int, ConfigProfile>
*/
protected function expandParentProfiles(Collection $chain): Collection
{
$expanded = new Collection;
$seen = [];
foreach ($chain as $profile) {
$this->addProfileWithParents($profile, $expanded, $seen);
}
return $expanded;
}
/**
* Add a profile and its parents to the chain.
*
* @param Collection<int, ConfigProfile> $chain
* @param array<int, bool> $seen
*/
protected function addProfileWithParents(ConfigProfile $profile, Collection $chain, array &$seen): void
{
if (isset($seen[$profile->id])) {
return;
}
$seen[$profile->id] = true;
$chain->push($profile);
// Follow parent chain
if ($profile->parent_profile_id !== null) {
$parent = $profile->parent;
if ($parent !== null) {
$this->addProfileWithParents($parent, $chain, $seen);
}
}
}
/**
* Check if a key prefix is configured.
*
* Optimised to use EXISTS query instead of resolving each key.
*
* @param object|null $workspace Workspace model instance or null for system scope
*/
public function isPrefixConfigured(
string $prefix,
?object $workspace = null,
string|Channel|null $channel = null,
): bool {
// Get profile IDs for this workspace
$profileChain = $this->buildProfileChain($workspace);
$profileIds = $profileChain->pluck('id')->all();
// Get channel IDs
$channelChain = $this->buildChannelChain($channel, $workspace);
$channelIds = $channelChain->map(fn ($c) => $c?->id)->all();
$actualChannelIds = array_filter($channelIds, fn ($id) => $id !== null);
// Single EXISTS query
return ConfigValue::whereIn('profile_id', $profileIds)
->where(function ($query) use ($actualChannelIds) {
$query->whereNull('channel_id');
if (! empty($actualChannelIds)) {
$query->orWhereIn('channel_id', $actualChannelIds);
}
})
->whereHas('key', function ($query) use ($prefix) {
$query->where('code', 'LIKE', "{$prefix}.%");
})
->exists();
}
}