Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"friendsofsymfony/http-cache-bundle": "^3.0",
"friendsofsymfony/jsrouting-bundle": "^3.5",
"ibexa/doctrine-schema": "~5.0.x-dev",
"ibexa/doctrine-migrations": "~5.0.x-dev",
"ibexa/jms-translation-bundle": "^2.6.1",
"league/flysystem-memory": "^2.0.6",
"liip/imagine-bundle": "^2.3",
Expand Down
39 changes: 39 additions & 0 deletions src/bundle/Core/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ public function getConfigTreeBuilder(): TreeBuilder
$this->addUrlWildcardsSection($rootNode);
$this->addOrmSection($rootNode);
$this->addUITranslationsSection($rootNode);
$this->addInstallerSection($rootNode);

// Delegate SiteAccess config to configuration parsers
$this->mainSiteAccessConfigParser->addSemanticConfig($this->generateScopeBaseNode($rootNode));
Expand Down Expand Up @@ -565,4 +566,42 @@ private function addUITranslationsSection(ArrayNodeDefinition $rootNode): void
->end()
->end();
}

/**
* Defines configuration for the "ibexa:install" installer.
*
* The configuration is available at:
* <code>
* ibexa:
* installer:
* schema_builder_event:
* enabled: true
* </code>
*
* @param \Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition $rootNode
*/
private function addInstallerSection(ArrayNodeDefinition $rootNode): ArrayNodeDefinition
{
return $rootNode
->children()
->arrayNode('installer')
->children()
->arrayNode('schema_builder_event')
->info('Configuration of the legacy, event-driven database schema building mechanism used by the "ibexa:install" command')
->children()
->booleanNode('enabled')
->defaultTrue()
->info(
'Whether "ibexa:install" dispatches Ibexa\Contracts\DoctrineSchema\Event\SchemaBuilderEvent ' .
'to let packages contribute their database schema via an event subscriber, as opposed to ' .
'the schema being installed from static SQL migrations. ' .
'Disable once all installed packages have migrated away from the event-driven mechanism.'
)
->end()
->end()
->end()
->end()
->end()
->end();
}
}
14 changes: 14 additions & 0 deletions src/bundle/Core/DependencyInjection/IbexaCoreExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ public function load(array $configs, ContainerBuilder $container): void

// Base services and services overrides
$loader->load('services.yml');
// Doctrine Migrations for core's own schema, alongside the legacy SchemaBuilderEvent path
$loader->load('doctrine_migrations.yml');
// Security services
$loader->load('security.yml');
// HTTP Kernel
Expand All @@ -134,6 +136,7 @@ public function load(array $configs, ContainerBuilder $container): void
$this->registerUrlWildcardsConfiguration($config, $container);
$this->registerOrmConfiguration($config, $container);
$this->registerUITranslationsConfiguration($config, $container);
$this->registerInstallerConfiguration($config, $container);

// Routing
$this->handleRouting($config, $container, $loader);
Expand Down Expand Up @@ -308,6 +311,17 @@ private function registerUITranslationsConfiguration(array $config, ContainerBui
$container->setParameter('ibexa.ui.translations.enabled', $config['ui']['translations']['enabled'] ?? false);
}

/**
* @param array<string, mixed> $config
*/
private function registerInstallerConfiguration(array $config, ContainerBuilder $container): void
{
$container->setParameter(
'ibexa.installer.schema_builder_event.enabled',
$config['installer']['schema_builder_event']['enabled'] ?? true
);
}

/**
* Handle routing parameters.
*
Expand Down
24 changes: 24 additions & 0 deletions src/bundle/Core/Resources/config/doctrine_migrations.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
services:
Ibexa\Bundle\RepositoryInstaller\Migration\InstallSchemaMigration:
autowire: true
public: false
arguments:
$connection: '@ibexa.persistence.connection'
tags:
- { name: !php/const Ibexa\Contracts\DoctrineMigrations\Migrations\IbexaMigrationTag::TAG }

Ibexa\Bundle\RepositoryInstaller\Migration\AddUrlAliasMlLinkIndexMigration:
autowire: true
public: false
arguments:
$connection: '@ibexa.persistence.connection'
tags:
- { name: !php/const Ibexa\Contracts\DoctrineMigrations\Migrations\IbexaMigrationTag::TAG }

Ibexa\Bundle\RepositoryInstaller\Migration\ImportDataMigration:
autowire: true
public: false
arguments:
$connection: '@ibexa.persistence.connection'
tags:
- { name: !php/const Ibexa\Contracts\DoctrineMigrations\Migrations\IbexaMigrationTag::TAG }
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\Bundle\RepositoryInstaller\Bootstrapper;

use Ibexa\Bundle\RepositoryInstaller\Migration\TaggedMigrationsRunner;
use Ibexa\Contracts\Test\Core\Bootstrapper\DatabaseSchemaHook;
use Ibexa\Contracts\Test\Core\Bootstrapper\FixtureHook;
use Ibexa\Contracts\Test\Core\Bootstrapper\HookInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

/**
* @internal for internal use by Ibexa packages' integration test setup
*
* Installs the database schema the way a real `ibexa:install` does when the SchemaBuilderEvent path
* is turned off: by running every Ibexa-tagged Doctrine migration via {@see TaggedMigrationsRunner}
* — the very same service {@see \Ibexa\Bundle\RepositoryInstaller\Installer\CoreInstaller} uses, so
* a test database built this way is built by the production code path, not by a re-implementation
* of it.
*
* This is the counterpart of {@see DatabaseSchemaHook}, and the two are mutually exclusive: each
* creates the same tables, so enabling both makes the second one fail on tables that already exist.
* Running an integration suite once with each is what proves the two install paths converge.
*
* Unlike every other built-in hook, this one is **disabled by default**. Enabling it by default
* would add a second schema install on top of {@see DatabaseSchemaHook}'s in every existing suite,
* breaking all of them the moment this package is upgraded. Opt in per bootstrap run, turning the
* legacy path off in the same breath:
*
* ```php
* (new Bootstrapper())->bootstrap(null, [
* DatabaseSchemaHook::class => [DatabaseSchemaHook::OPTION_LOAD_SCHEMA => false],
* DoctrineMigrationsSchemaHook::class => [DoctrineMigrationsSchemaHook::OPTION_INSTALL_SCHEMA => true],
* ]);
* ```
*
* Registered only in the "test" environment, and removed from the container along with
* {@see TaggedMigrationsRunner} itself when "ibexa/doctrine-migrations" isn't installed/enabled, by
* {@see \Ibexa\Bundle\RepositoryInstaller\DependencyInjection\Compiler\RemoveTaggedMigrationsRunnerPass}.
*/
final class DoctrineMigrationsSchemaHook implements HookInterface
{
/**
* Fixed tag priority this hook is registered at — after ibexa/test-core's
* {@see DatabaseSchemaHook} (1000), before its {@see FixtureHook} (900).
*
* Ordering against DatabaseSchemaHook only matters when both are enabled, which is a
* misconfiguration; running second makes that misconfiguration fail at a predictable point
* ("table already exists" out of the migrations) rather than in whichever order the tag
* collection happened to produce.
*/
public const PRIORITY = 990;

public const OPTION_INSTALL_SCHEMA = 'install_schema';

private TaggedMigrationsRunner $taggedMigrationsRunner;

public function __construct(TaggedMigrationsRunner $taggedMigrationsRunner)
{
$this->taggedMigrationsRunner = $taggedMigrationsRunner;
}

public function configureOptions(OptionsResolver $resolver): void
{
$resolver->define(self::OPTION_INSTALL_SCHEMA)
->default(false)
->allowedTypes('bool');
}

public function __invoke(array $options): void
{
if (!$options[self::OPTION_INSTALL_SCHEMA]) {
return;
}

// Executions stay recorded in the Doctrine Migrations versioning table on purpose - that
// is what a real install leaves behind, and it is what makes a second run (a test calling
// the installer itself, say) correctly skip instead of re-applying. Contrast with
// ibexa/migrations' MigrationHook, which resets its own bookkeeping because its migrations
// are test fixtures rather than part of the installed state.
$this->taggedMigrationsRunner->run();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,45 +7,20 @@

namespace Ibexa\Bundle\RepositoryInstaller\DependencyInjection\Compiler;

use Ibexa\Bundle\RepositoryInstaller\Command\InstallPlatformCommand;
use LogicException;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;

/**
* Injects services tagged as "ibexa.installer" into
* {@see \Ibexa\Bundle\RepositoryInstaller\Command\InstallPlatformCommand::$installers}.
* @deprecated 4.6.27 Installers are now injected into
* {@see \Ibexa\Bundle\RepositoryInstaller\Command\InstallPlatformCommand::$installers} via a
* `!tagged_locator` argument configured in services.yml, so this compiler pass is no longer needed.
* Will be removed in 5.0.
*/
class InstallerTagPass implements CompilerPassInterface
{
public const string INSTALLER_TAG = 'ibexa.installer';

public function process(ContainerBuilder $container): void
{
if (!$container->hasDefinition(InstallPlatformCommand::class)) {
return;
}

$installCommandDef = $container->findDefinition(InstallPlatformCommand::class);
$installers = [];

foreach ($container->findTaggedServiceIds(self::INSTALLER_TAG) as $id => $tags) {
foreach ($tags as $tag) {
if (!isset($tag['type'])) {
throw new LogicException(
sprintf(
'Service tag %s needs a "type" attribute to identify the installer. You need to provide a tag for %s.',
self::INSTALLER_TAG,
$id
)
);
}

$installers[$tag['type']] = new Reference($id);
}
}

$installCommandDef->replaceArgument('$installers', $installers);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\Bundle\RepositoryInstaller\DependencyInjection\Compiler;

use Doctrine\Migrations\Provider\SchemaProvider;
use Ibexa\Bundle\RepositoryInstaller\Migration\SchemaBuilderEventSchemaProvider;
use Ibexa\Contracts\DoctrineMigrations\Migrations\IbexaOnlyDependencyFactory;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;

/**
* Wires {@see SchemaBuilderEventSchemaProvider} onto
* {@see IbexaOnlyDependencyFactory::SERVICE_ID} as its {@see SchemaProvider}, so
* "ibexa:doctrine:migrations:diff"/":generate" (registered by ibexa/doctrine-migrations) can
* compare against the schema Ibexa's SchemaBuilderEvent-based packages expect.
*
* A no-op if either ibexa/doctrine-migrations or ibexa/doctrine-schema isn't installed/enabled --
* both are optional as far as this bundle is concerned.
*/
final class RegisterSchemaBuilderEventSchemaProviderPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (
!$container->hasDefinition(IbexaOnlyDependencyFactory::SERVICE_ID)
|| !$container->hasDefinition(SchemaBuilderEventSchemaProvider::class)
) {
return;
}

$container->getDefinition(IbexaOnlyDependencyFactory::SERVICE_ID)
->addMethodCall('setService', [SchemaProvider::class, new Reference(SchemaBuilderEventSchemaProvider::class)]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\Bundle\RepositoryInstaller\DependencyInjection\Compiler;

use Ibexa\Bundle\RepositoryInstaller\Bootstrapper\DoctrineMigrationsSchemaHook;
use Ibexa\Bundle\RepositoryInstaller\Migration\TaggedMigrationsRunner;
use Ibexa\Contracts\DoctrineMigrations\Migrations\IbexaOnlyDependencyFactory;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;

/**
* Removes the {@see TaggedMigrationsRunner} service definition - and
* {@see DoctrineMigrationsSchemaHook}, which cannot be built without it - when
* {@see IbexaOnlyDependencyFactory::SERVICE_ID} isn't available - i.e. "ibexa/doctrine-migrations"
* isn't installed/enabled - since {@see TaggedMigrationsRunner} requires a real
* {@see \Doctrine\Migrations\DependencyFactory} and can no longer be built with none available.
*
* Runs before the reference-validity check (which happens in the "before removing" compiler pass
* stage), so the still-mandatory "$dependencyFactory" argument on the (now-removed) definition
* never gets a chance to fail container compilation.
*/
final class RemoveTaggedMigrationsRunnerPass implements CompilerPassInterface
{
/**
* @var list<string> definitions that cannot survive without a real DependencyFactory, in
* dependent-first order
*/
private const DEFINITIONS = [
DoctrineMigrationsSchemaHook::class,
TaggedMigrationsRunner::class,
];

public function process(ContainerBuilder $container): void
{
if ($container->hasDefinition(IbexaOnlyDependencyFactory::SERVICE_ID)) {
return;
}

foreach (self::DEFINITIONS as $id) {
if ($container->hasDefinition($id)) {
$container->removeDefinition($id);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

namespace Ibexa\Bundle\RepositoryInstaller\DependencyInjection;

use Ibexa\Contracts\Test\Core\Bootstrapper\HookInterface;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
Expand All @@ -21,5 +22,20 @@ public function load(array $configs, ContainerBuilder $container): void
{
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config'));
$loader->load('services.yml');

if ($this->shouldRegisterBootstrapperHook($container)) {
$loader->load('bootstrapper.yaml');
}
}

/**
* "ibexa/test-core" is a dev dependency, so its HookInterface is absent from a production
* install - and even where it is present, integration-test bootstrap services have no business
* being built outside the "test" environment.
*/
private function shouldRegisterBootstrapperHook(ContainerBuilder $container): bool
{
return 'test' === $container->getParameter('kernel.environment')
&& interface_exists(HookInterface::class);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
namespace Ibexa\Bundle\RepositoryInstaller;

use Ibexa\Bundle\DoctrineSchema\DoctrineSchemaBundle;
use Ibexa\Bundle\RepositoryInstaller\DependencyInjection\Compiler\InstallerTagPass;
use Ibexa\Bundle\RepositoryInstaller\DependencyInjection\Compiler\RegisterSchemaBuilderEventSchemaProviderPass;
use Ibexa\Bundle\RepositoryInstaller\DependencyInjection\Compiler\RemoveTaggedMigrationsRunnerPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Exception\RuntimeException;
use Symfony\Component\HttpKernel\Bundle\Bundle;
Expand All @@ -30,6 +31,7 @@ public function build(ContainerBuilder $container): void
}

parent::build($container);
$container->addCompilerPass(new InstallerTagPass());
$container->addCompilerPass(new RegisterSchemaBuilderEventSchemaProviderPass());
$container->addCompilerPass(new RemoveTaggedMigrationsRunnerPass());
}
}
Loading
Loading