Skip to content

Add TypeRegistry to Configuration and use it in all internal type resolution#7342

Open
GromNaN wants to merge 10 commits intodoctrine:4.5.xfrom
GromNaN:type-registry-config
Open

Add TypeRegistry to Configuration and use it in all internal type resolution#7342
GromNaN wants to merge 10 commits intodoctrine:4.5.xfrom
GromNaN:type-registry-config

Conversation

@GromNaN
Copy link
Copy Markdown
Member

@GromNaN GromNaN commented Mar 29, 2026

Q A
Type feature
Fixed issues -

Continues #6705. The existing TypeRegistry class already supports scoped, instance-based
type management; this PR wires it into the rest of DBAL so it is actually used.

A PR to Doctrine ORM and DoctrineBundle will follow.
This is already being done in the same way for Doctrine MongoDB ODM: doctrine/mongodb-odm#2966

Disclamer: This PR was made using Claude under my very close supervision; trying to provide a more detailed description of the changes than I would have written myself, but I have verified the content and made some edits for clarity and accuracy.

Motivation

Two goals:

  • Dependency injection into type instances. Custom types sometimes need access to
    services (e.g. a serializer, an encryption service). With the global static registry this is impossible
    without static state. A per-connection TypeRegistry can hold fully-constructed type
    instances.

  • Prevent global side-effects. Type::addType() and Type::overrideType() mutate a
    process-wide singleton, so a test or a bundle changing a type affects every connection.
    A scoped registry isolates those changes.

What changed

TypeRegistry

  • Built-in types are now pre-populated by the constructor: new TypeRegistry() already
    contains all built-in types. Custom types passed as constructor arguments are layered
    on top and may override built-ins by name.

Configuration

  • getTypeRegistry(): TypeRegistry returns the registry for this connection, lazily
    defaulting to the global one (Type::getTypeRegistry()).
  • setTypeRegistry(TypeRegistry $registry): self injects a custom registry scoped to
    this connection.

Internal type resolution

All of the following now resolve types through $configuration->getTypeRegistry() instead
of the static Type::* methods:

Component Method(s) changed
Connection convertToDatabaseValue(), convertToPHPValue(), getBindingInfo()
Statement parameter binding
AbstractPlatform initializeAllDoctrineTypeMappings(), registerDoctrineTypeMapping()
*SchemaManager (×6) _getPortableTableColumnDefinition()
*MetadataProvider (×6) column type resolution (switched from ->setTypeName() to ->setType())
Table addColumn() uses the TypeRegistry from SchemaConfig when available
Schema createTable() forwards the TypeRegistry from SchemaConfig to each Table
SchemaConfig carries a TypeRegistry; populated by AbstractSchemaManager::createSchemaConfig() from the connection configuration

AbstractPlatform receives its Configuration via a new setConfiguration() method
called by Connection::getDatabasePlatform() after platform creation.

AbstractSchemaManager::createSchemaConfig() now sets the TypeRegistry from the connection
so that every Table created via Schema::createTable() (including from ORM's SchemaTool)
resolves types through the per-connection registry.

Static Type::* methods are kept

Type::getType(), Type::addType(), Type::overrideType(), Type::hasType(), and
Type::getTypesMap() still delegate to the global singleton and remain the fallback for
user code that creates Table or ColumnEditor without a Configuration.

Trade-offs

Table::addColumn() falls back to the global registry when no TypeRegistry is present
in the SchemaConfig. Enforcing the instance registry here would require a breaking change.
Internal callers have been updated to go through SchemaConfig, so the fallback is only
hit from user code that constructs Table directly without a SchemaConfig.

ColumnDiff::hasTypeChanged() still compares by class name, which is insufficient now
that two distinct service instances can share the same class (e.g. two differently
configured MoneyType instances), and already incorrect for built-in aliases like json
and json_object which share JsonType::class but are distinct registered names.
The correct fix is to compare by instance identity (===) once both sides of the diff
are guaranteed to resolve from the same TypeRegistry. This is left as a follow-up.

Custom types registered via Type::addType() are invisible to connections that use a
custom TypeRegistry.
This is intentional: it is the isolation the feature provides.
Users who set a custom registry are responsible for registering all types they need in it.

The global singleton is preserved. Type::getTypeRegistry() still returns the
process-wide registry. Connections that do not call setTypeRegistry() continue to behave
exactly as before.

GromNaN added 8 commits March 29, 2026 01:41
…instance-based lookups

All internal type resolution (Connection, Statement, AbstractPlatform, SchemaManagers,
MetadataProviders) now goes through Configuration::getTypeRegistry() instead of the
global Type::getType() / Type::hasType() / Type::getTypesMap() static methods.

Table receives an optional Configuration so addColumn() uses the instance registry
when available, falling back to Type::getType() for user code without a Configuration.
ColumnEditor::setTypeName() similarly falls back to Type::getType() for user code;
internal callers (MetadataProviders) now use setType() with the configuration registry.
…in types by default

The constant is now TypeRegistry::BUILTIN_TYPES_MAP (public). Any new TypeRegistry()
is pre-populated with all built-in type instances; additional types passed to the
constructor are registered on top and may override built-ins.

Type::getTypeRegistry() is simplified to new TypeRegistry() with no arguments.
SchemaConfig now carries a TypeRegistry that is populated by
AbstractSchemaManager::createSchemaConfig() from the connection
configuration. Schema::createTable() forwards it to each Table so
that Table::addColumn() resolves types from the per-connection
TypeRegistry instead of falling back to the global static registry.

Table's constructor parameter is changed from ?Configuration to
?TypeRegistry directly, since Configuration was only needed to reach
its TypeRegistry.

A TODO comment is added to ColumnDiff::hasTypeChanged() noting that
the current class-based comparison is insufficient now that types are
services: same-class aliases (json / json_object) produce false
negatives, and distinct service instances of the same class would not
be detected. The fix (identity comparison) is left for a follow-up.
@stof
Copy link
Copy Markdown
Member

stof commented Apr 3, 2026

As discussed during the SymfonyLive, it would be great to support lazy-loading of type instances injected in the TypeRegistry, to reduce the cost of instantiation the connection service (especially when types have dependencies, which might lead to instantiating a bigger object graph).

As far as DoctrineBundle is concerned, the easier way would probably involve injecting a PSR-12 ContainerInterface (with ids being the type names) and a list of type names (or a map of type names to ids in the container to allow more flexibility about those ids). This would allow us to use the Symfony ServiceLocator which performs such lazy-loading (it would of course mean that the constructor should not retrieve type instances, as that would defeat the lazy-loading).
An alternative implementation could be to use \Symfony\Contracts\Service\ServiceProviderInterface which avoids the need for the separate list of available ids (as the getProvidedServices method allows introspecting the container) but this would introduce a dependency on symfony/service-contracts which might be an issue for other frameworks.

@GromNaN
Copy link
Copy Markdown
Member Author

GromNaN commented Apr 3, 2026

Thanks for the reminder @stof!

I implemented both approaches:

  • Symfony ServiceProviderInterface (from symfony/service-contracts, added as an optional require-dev dependency): pass it directly as the constructor argument. getProvidedServices() is called during construction to register factory entries lazily — no type instances are created until the first get() call.

  • PSR ContainerInterface: pass an array<string, ContainerInterface> where each key is the type name and the value is a container that resolves it. This avoids the symfony/service-contracts dependency entirely.

Both paths converge into a unified $factories array (array<string, class-string<Type>|ContainerInterface>) that also handles built-in types lazily. DoctrineBundle can pass a Symfony ServiceLocator either as a ServiceProviderInterface (preferred) or as individual ContainerInterface entries in the array.

- Accept ServiceProviderInterface<Type> or array<string, Type|ContainerInterface>
  as constructor argument; built-in types are now lazy too
- Store unresolved types as class-string (built-ins) or ContainerInterface
  in a $factories array; instances are created on first get() and cached
- Replace $instancesReverseIndex spl_object_id map with WeakMap<Type, string>
  for O(1) reverse lookup: 84 ns/op vs 86 ns/op (spl_id) vs 171 ns/op (array_search)
  for 30 registered types, while also being GC-friendly
@GromNaN GromNaN force-pushed the type-registry-config branch from d87fe57 to 0b6f833 Compare April 3, 2026 16:10
private array $factories = [];

/** @var WeakMap<Type, string> */
private WeakMap $instancesReverseIndex;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to get rid of this $instancesReverseIndex by removing the lookupName function. This means there will no longer be a restriction preventing you from registering the same type instance under multiple names.

@stof
Copy link
Copy Markdown
Member

stof commented Apr 7, 2026

I find it weird to pass multiple ContainerInterface. A single container can hold all the types (under different indexes).
My proposal was to have separate arguments to pass a ContainerInterface and a list of ids in it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants