Skip to content

Commit b78e201

Browse files
committed
feat(java): programmatic provider registration on MetaDataRegistry
Mirrors the cross-port composition pattern already shipped in the TypeScript port (composeRegistry(providers)) and the Python port (compose_registry(providers)). Until now the Java MetaDataRegistry only exposed ServiceLoader auto-discovery for MetaDataTypeProvider instances — embedded scenarios where ServiceLoader is awkward (fat-jar consolidation, GraalVM native-image, some app servers), tests that want to inject a fixture provider without touching META-INF/services, and conditional / tenant-driven composition all had to work around it. Two new public entry points on MetaDataRegistry: // Static factory — fresh registry from an explicit list MetaDataRegistry registry = MetaDataRegistry.compose(List.of( new CoreTypesProvider(), new MyDomainProvider(), new MyTenantProvider() )); // Instance method — add providers to an existing registry registry.registerProviders(List.of(otherProvider)); Cross-port error contract enforced strictly (matches TS / Python): - Duplicate provider id → MetaDataException(ERR_PROVIDER_DUPLICATE_ID) - Missing dependency → MetaDataException(ERR_PROVIDER_MISSING_DEPENDENCY) - Dependency cycle → MetaDataException(ERR_PROVIDER_DEPENDENCY_CYCLE) - Provider register fail → MetaDataException(ERR_UNKNOWN, wrapping cause) The existing ServiceLoader path on MetaDataRegistry.getInstance() keeps its warn-and-continue semantics — appropriate for runtime robustness in production apps. The new programmatic path is strict, matching the cross-port spec. Implementation: resolveDependenciesStrict() + topologicalSortStrict() sit alongside the existing warn-and-continue resolveDependencies() / topologicalSort() in MetaDataRegistry, sharing the same topo sort algorithm but throwing structured exceptions instead of logging. Tests: ComposeRegistryTest covers the six scenarios: - compose builds registry and runs each provider against it - topological ordering regardless of input order - duplicate-id throws ERR_PROVIDER_DUPLICATE_ID - missing-dependency throws ERR_PROVIDER_MISSING_DEPENDENCY - dependency-cycle throws ERR_PROVIDER_DEPENDENCY_CYCLE - registerProviders adds to an existing registry Metadata test suite: 724 tests / 5 failures — same 5 pre-existing failures (4 provider-extension fixtures + 1 docs-file-basic canonical-serialization), +6 from this commit, zero regressions. Documented in MIGRATION.md and the 7.0.1-SNAPSHOT entry of RELEASE_NOTES.md.
1 parent 3595d58 commit b78e201

4 files changed

Lines changed: 326 additions & 2 deletions

File tree

server/java/MIGRATION.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,22 @@ Consumers running inside an OSGi container can still wrap MetaObjects' JARs with
4343

4444
The `WeakReference` patterns in `MetaData`, `HybridCache`, and `StandardServiceRegistry` stay — they are general ClassLoader-leak prevention, not OSGi-specific.
4545

46+
### Programmatic provider registration (new in 7.0.1)
47+
48+
`MetaDataRegistry` now ships a `compose(...)` / `registerProviders(...)` API that mirrors the cross-port pattern already in TypeScript (`composeRegistry(providers)`) and Python (`compose_registry(providers)`):
49+
50+
```java
51+
MetaDataRegistry registry = MetaDataRegistry.compose(List.of(
52+
new CoreTypesProvider(),
53+
new MyDomainProvider(),
54+
new MyTenantProvider()
55+
));
56+
```
57+
58+
Use this when ServiceLoader is awkward — embedded scenarios, fat-jar consolidation, GraalVM native-image, conditional / tenant-driven composition, and tests that want to inject a fixture provider without touching `META-INF/services`. Strict error contract matches the cross-port spec: duplicate provider id, missing dependency, or dependency cycle throws `MetaDataException` with the matching `ErrorCode`.
59+
60+
The default `MetaDataRegistry.getInstance()` still uses ServiceLoader auto-discovery with warn-and-continue semantics — no change for the typical app.
61+
4662
The `metaobjects-codegen-mustache` and `metaobjects-codegen-plantuml` modules from 6.x continue unchanged.
4763

4864
### Step 3 — source paradigm v2 ([ADR-0007](../../spec/decisions/ADR-0007-source-paradigm-v2.md))

server/java/RELEASE_NOTES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Post-7.0.0 cleanup line:
1515
- **OSGi runtime variant removed.** `OSGIServiceRegistry`, `BundleLifecycleManager`, and the `maven-bundle-plugin` packaging on every reactor module are gone. All artifacts now ship as plain JARs. The `ServiceRegistry` interface drops the OSGi-specific methods; `StandardServiceRegistry` is the only implementation. See [MIGRATION.md](MIGRATION.md#osgi-support-removed) for the migration path (OSGi consumers can wrap our JARs with `bnd` / `pax-url`).
1616
- **`metaobjects-dynamic-core` folded into `metaobjects-metadata`.** Its 89-LoC `CoreObjectsMetaDataProvider` now ships inside the metadata jar; the module is removed from the reactor. Reactor module count: 14 → 13.
1717
- **`archetype` and `examples` directories deleted.** They had been out of the reactor since 7.0.0; carrying them as scaffold source risked bit-rot.
18+
- **Programmatic provider registration.** `MetaDataRegistry.compose(Collection<MetaDataTypeProvider>)` and `MetaDataRegistry.registerProviders(Collection<...>)` mirror the cross-port pattern already shipped in TypeScript (`composeRegistry(providers)`) and Python (`compose_registry(providers)`). Strict cross-port error contract: duplicate provider id throws `MetaDataException` with `ERR_PROVIDER_DUPLICATE_ID`; missing dependency throws `ERR_PROVIDER_MISSING_DEPENDENCY`; dependency cycle throws `ERR_PROVIDER_DEPENDENCY_CYCLE`. The ServiceLoader auto-discovery path on `MetaDataRegistry.getInstance()` continues to work unchanged (warn-and-continue semantics, for runtime robustness).
1819

1920
---
2021

server/java/metadata/src/main/java/com/metaobjects/registry/MetaDataRegistry.java

Lines changed: 139 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,10 +129,75 @@ public MetaDataRegistry(ServiceRegistry serviceRegistry) {
129129
public MetaDataRegistry() {
130130
this(ServiceRegistryFactory.getDefault());
131131
}
132-
132+
133+
// =========================================================================
134+
// Programmatic provider composition (mirrors composeRegistry in TS /
135+
// compose_registry in Python). Use this when you want explicit, in-process
136+
// control over which providers register types — tests, embedded scenarios
137+
// where ServiceLoader is awkward, and conditional / framework-driven
138+
// composition. ServiceLoader auto-discovery via {@link #getInstance()} is
139+
// the default for typical applications and stays unchanged.
140+
// =========================================================================
141+
142+
/**
143+
* Compose a fresh {@link MetaDataRegistry} from the given providers and
144+
* return it. Mirrors {@code composeRegistry(providers)} in the TypeScript
145+
* port and {@code compose_registry(providers)} in the Python port.
146+
*
147+
* <p>Providers are topologically sorted by their declared dependencies
148+
* (stable sort — providers with no ordering constraint between them keep
149+
* input order), then each provider's {@link MetaDataTypeProvider#registerTypes}
150+
* runs against the new registry.</p>
151+
*
152+
* @param providers the providers to compose; must not contain duplicate
153+
* ids, missing dependencies, or dependency cycles
154+
* @return a new registry with all provider types registered
155+
* @throws MetaDataException with {@link com.metaobjects.ErrorCode#ERR_PROVIDER_DUPLICATE_ID},
156+
* {@link com.metaobjects.ErrorCode#ERR_PROVIDER_MISSING_DEPENDENCY},
157+
* or {@link com.metaobjects.ErrorCode#ERR_PROVIDER_DEPENDENCY_CYCLE}
158+
* if the provider set is malformed.
159+
*/
160+
public static MetaDataRegistry compose(Collection<MetaDataTypeProvider> providers) {
161+
Objects.requireNonNull(providers, "providers must not be null");
162+
MetaDataRegistry registry = new MetaDataRegistry();
163+
registry.registerProviders(providers);
164+
return registry;
165+
}
166+
167+
/**
168+
* Register a collection of providers into THIS registry. Strict
169+
* counterpart to ServiceLoader auto-discovery — duplicate ids, missing
170+
* dependencies, and dependency cycles throw {@link MetaDataException}
171+
* with the matching {@link com.metaobjects.ErrorCode} rather than logging
172+
* warnings and continuing.
173+
*
174+
* @param providers the providers to register
175+
* @throws MetaDataException on duplicate id, missing dependency, or cycle
176+
*/
177+
public synchronized void registerProviders(Collection<MetaDataTypeProvider> providers) {
178+
Objects.requireNonNull(providers, "providers must not be null");
179+
List<MetaDataTypeProvider> ordered = resolveDependenciesStrict(providers);
180+
for (MetaDataTypeProvider provider : ordered) {
181+
try {
182+
provider.registerTypes(this);
183+
if (!deferredInheritanceTypes.isEmpty()) {
184+
resolveDeferredInheritance();
185+
}
186+
} catch (com.metaobjects.MetaDataException e) {
187+
throw e;
188+
} catch (Exception e) {
189+
com.metaobjects.MetaDataException wrap = new com.metaobjects.MetaDataException(
190+
"Provider '" + provider.getProviderId() + "' failed during registerTypes: " + e.getMessage(),
191+
com.metaobjects.ErrorCode.ERR_UNKNOWN);
192+
wrap.initCause(e);
193+
throw wrap;
194+
}
195+
}
196+
}
197+
133198
/**
134199
* Register a MetaData type with fluent configuration
135-
*
200+
*
136201
* @param clazz Implementation class
137202
* @param configurator Configuration function for the type definition
138203
*/
@@ -985,6 +1050,78 @@ private void topologicalSort(MetaDataTypeProvider provider,
9851050
result.add(provider);
9861051
}
9871052

1053+
/**
1054+
* Strict counterpart to {@link #resolveDependencies(Collection)}. Used by
1055+
* the programmatic {@link #compose(Collection)} / {@link #registerProviders(Collection)}
1056+
* entry points to match the cross-port error contract: duplicate ids,
1057+
* missing dependencies, and dependency cycles throw {@link com.metaobjects.MetaDataException}
1058+
* with the matching {@link com.metaobjects.ErrorCode} rather than logging and continuing.
1059+
*/
1060+
private List<MetaDataTypeProvider> resolveDependenciesStrict(Collection<MetaDataTypeProvider> providers) {
1061+
Map<String, MetaDataTypeProvider> providerMap = new HashMap<>();
1062+
for (MetaDataTypeProvider provider : providers) {
1063+
String id = provider.getProviderId();
1064+
if (providerMap.containsKey(id)) {
1065+
throw new com.metaobjects.MetaDataException(
1066+
"Duplicate provider id '" + id + "': "
1067+
+ providerMap.get(id).getClass().getName() + " vs "
1068+
+ provider.getClass().getName(),
1069+
com.metaobjects.ErrorCode.ERR_PROVIDER_DUPLICATE_ID);
1070+
}
1071+
providerMap.put(id, provider);
1072+
}
1073+
1074+
List<String> missing = new ArrayList<>();
1075+
for (MetaDataTypeProvider provider : providers) {
1076+
for (String dep : provider.getDependencies()) {
1077+
if (!providerMap.containsKey(dep)) {
1078+
missing.add(dep + " (required by " + provider.getProviderId() + ")");
1079+
}
1080+
}
1081+
}
1082+
if (!missing.isEmpty()) {
1083+
throw new com.metaobjects.MetaDataException(
1084+
"Missing provider dependencies: " + String.join(", ", missing),
1085+
com.metaobjects.ErrorCode.ERR_PROVIDER_MISSING_DEPENDENCY);
1086+
}
1087+
1088+
List<MetaDataTypeProvider> result = new ArrayList<>();
1089+
Set<String> visited = new HashSet<>();
1090+
Set<String> visiting = new HashSet<>();
1091+
for (MetaDataTypeProvider provider : providers) {
1092+
if (!visited.contains(provider.getProviderId())) {
1093+
topologicalSortStrict(provider, providerMap, visited, visiting, result);
1094+
}
1095+
}
1096+
return result;
1097+
}
1098+
1099+
private void topologicalSortStrict(MetaDataTypeProvider provider,
1100+
Map<String, MetaDataTypeProvider> providerMap,
1101+
Set<String> visited,
1102+
Set<String> visiting,
1103+
List<MetaDataTypeProvider> result) {
1104+
String providerId = provider.getProviderId();
1105+
if (visiting.contains(providerId)) {
1106+
throw new com.metaobjects.MetaDataException(
1107+
"Circular dependency detected involving provider: " + providerId,
1108+
com.metaobjects.ErrorCode.ERR_PROVIDER_DEPENDENCY_CYCLE);
1109+
}
1110+
if (visited.contains(providerId)) {
1111+
return;
1112+
}
1113+
visiting.add(providerId);
1114+
for (String depId : provider.getDependencies()) {
1115+
MetaDataTypeProvider dependency = providerMap.get(depId);
1116+
if (dependency != null) {
1117+
topologicalSortStrict(dependency, providerMap, visited, visiting, result);
1118+
}
1119+
}
1120+
visiting.remove(providerId);
1121+
visited.add(providerId);
1122+
result.add(provider);
1123+
}
1124+
9881125
/**
9891126
* Resolve deferred inheritance for types whose parents weren't available during initial registration.
9901127
* This method should be called after all static type registrations have completed.
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package com.metaobjects.registry;
2+
3+
import com.metaobjects.ErrorCode;
4+
import com.metaobjects.MetaDataException;
5+
import org.junit.Test;
6+
7+
import java.util.ArrayList;
8+
import java.util.List;
9+
10+
import static org.junit.Assert.assertEquals;
11+
import static org.junit.Assert.assertNotNull;
12+
import static org.junit.Assert.assertSame;
13+
import static org.junit.Assert.assertTrue;
14+
import static org.junit.Assert.fail;
15+
16+
/**
17+
* Tests for the programmatic provider-composition API on {@link MetaDataRegistry}.
18+
*
19+
* <p>Mirrors the cross-port pattern shipped by the TypeScript port
20+
* ({@code composeRegistry(providers)}) and the Python port
21+
* ({@code compose_registry(providers)}). This Java entry point lets callers
22+
* build a registry by handing it an explicit list of providers — useful for
23+
* tests, embedded scenarios where ServiceLoader is awkward, and conditional
24+
* / framework-driven composition.</p>
25+
*/
26+
public class ComposeRegistryTest {
27+
28+
/** A minimal recording provider that captures the registry it was registered against. */
29+
static final class RecordingProvider implements MetaDataTypeProvider {
30+
private final String id;
31+
private final String[] deps;
32+
MetaDataRegistry registeredAgainst;
33+
34+
RecordingProvider(String id, String... deps) {
35+
this.id = id;
36+
this.deps = deps;
37+
}
38+
39+
@Override
40+
public String getProviderId() {
41+
return id;
42+
}
43+
44+
@Override
45+
public String[] getDependencies() {
46+
return deps;
47+
}
48+
49+
@Override
50+
public void registerTypes(MetaDataRegistry registry) {
51+
this.registeredAgainst = registry;
52+
}
53+
54+
@Override
55+
public String getDescription() {
56+
return "recording-provider:" + id;
57+
}
58+
}
59+
60+
/** A provider that records the order it ran in (mutating a shared list). */
61+
static final class OrderRecordingProvider implements MetaDataTypeProvider {
62+
private final String id;
63+
private final String[] deps;
64+
private final List<String> log;
65+
66+
OrderRecordingProvider(String id, List<String> log, String... deps) {
67+
this.id = id;
68+
this.log = log;
69+
this.deps = deps;
70+
}
71+
72+
@Override
73+
public String getProviderId() {
74+
return id;
75+
}
76+
77+
@Override
78+
public String[] getDependencies() {
79+
return deps;
80+
}
81+
82+
@Override
83+
public void registerTypes(MetaDataRegistry registry) {
84+
log.add(id);
85+
}
86+
}
87+
88+
@Test
89+
public void compose_buildsRegistry_andRunsEachProviderAgainstIt() {
90+
RecordingProvider a = new RecordingProvider("a");
91+
RecordingProvider b = new RecordingProvider("b");
92+
93+
MetaDataRegistry registry = MetaDataRegistry.compose(List.of(a, b));
94+
95+
assertNotNull("compose() should return a non-null registry", registry);
96+
assertSame("provider a should have registered against the returned registry",
97+
registry, a.registeredAgainst);
98+
assertSame("provider b should have registered against the returned registry",
99+
registry, b.registeredAgainst);
100+
}
101+
102+
@Test
103+
public void compose_runsProvidersInDependencyOrder() {
104+
List<String> order = new ArrayList<>();
105+
OrderRecordingProvider a = new OrderRecordingProvider("a", order);
106+
OrderRecordingProvider b = new OrderRecordingProvider("b", order, "a");
107+
OrderRecordingProvider c = new OrderRecordingProvider("c", order, "b");
108+
109+
// Feed in reverse order — topo sort should still produce a, b, c.
110+
MetaDataRegistry.compose(List.of(c, b, a));
111+
112+
assertEquals("providers must run in topological order regardless of input order",
113+
List.of("a", "b", "c"), order);
114+
}
115+
116+
@Test
117+
public void compose_throwsDuplicateId() {
118+
RecordingProvider dup1 = new RecordingProvider("dup");
119+
RecordingProvider dup2 = new RecordingProvider("dup");
120+
121+
try {
122+
MetaDataRegistry.compose(List.of(dup1, dup2));
123+
fail("expected MetaDataException with ERR_PROVIDER_DUPLICATE_ID");
124+
} catch (MetaDataException e) {
125+
assertEquals(ErrorCode.ERR_PROVIDER_DUPLICATE_ID, e.getCode().orElse(null));
126+
assertTrue("message should call out the duplicate id",
127+
e.getMessage().contains("dup"));
128+
}
129+
}
130+
131+
@Test
132+
public void compose_throwsMissingDependency() {
133+
RecordingProvider provider = new RecordingProvider("dependent", "absent");
134+
135+
try {
136+
MetaDataRegistry.compose(List.of(provider));
137+
fail("expected MetaDataException with ERR_PROVIDER_MISSING_DEPENDENCY");
138+
} catch (MetaDataException e) {
139+
assertEquals(ErrorCode.ERR_PROVIDER_MISSING_DEPENDENCY, e.getCode().orElse(null));
140+
assertTrue("message should call out the missing dependency id",
141+
e.getMessage().contains("absent"));
142+
assertTrue("message should identify the requiring provider",
143+
e.getMessage().contains("dependent"));
144+
}
145+
}
146+
147+
@Test
148+
public void compose_throwsDependencyCycle() {
149+
RecordingProvider a = new RecordingProvider("a", "b");
150+
RecordingProvider b = new RecordingProvider("b", "a");
151+
152+
try {
153+
MetaDataRegistry.compose(List.of(a, b));
154+
fail("expected MetaDataException with ERR_PROVIDER_DEPENDENCY_CYCLE");
155+
} catch (MetaDataException e) {
156+
assertEquals(ErrorCode.ERR_PROVIDER_DEPENDENCY_CYCLE, e.getCode().orElse(null));
157+
}
158+
}
159+
160+
@Test
161+
public void registerProviders_addsToExistingRegistry() {
162+
MetaDataRegistry registry = new MetaDataRegistry();
163+
RecordingProvider added = new RecordingProvider("added");
164+
165+
registry.registerProviders(List.of(added));
166+
167+
assertSame("registerProviders should hand the receiving registry to the provider",
168+
registry, added.registeredAgainst);
169+
}
170+
}

0 commit comments

Comments
 (0)