1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace LaravelUi5\OData\Service\Builder;
6:
7: use LaravelUi5\OData\Edm\Container\EntityContainer;
8: use LaravelUi5\OData\Edm\Container\EntitySet;
9: use LaravelUi5\OData\Edm\Container\NavigationPropertyBinding;
10: use LaravelUi5\OData\Edm\Contracts\Container\EntitySetInterface;
11: use LaravelUi5\OData\Edm\Contracts\Container\FunctionImportInterface;
12: use LaravelUi5\OData\Edm\Contracts\Container\SingletonInterface;
13: use LaravelUi5\OData\Edm\Contracts\Type\EnumTypeInterface;
14: use LaravelUi5\OData\Edm\Contracts\EdmxInterface;
15: use LaravelUi5\OData\Edm\Contracts\FunctionInterface;
16: use LaravelUi5\OData\Edm\Contracts\ReferenceInterface;
17: use LaravelUi5\OData\Edm\Contracts\Type\ComplexTypeInterface;
18: use LaravelUi5\OData\Edm\Contracts\Property\NavigationPropertyInterface;
19: use LaravelUi5\OData\Edm\Contracts\Type\EntityTypeInterface;
20: use LaravelUi5\OData\Edm\Contracts\Type\TypeDefinitionInterface;
21: use LaravelUi5\OData\Edm\Edmx;
22: use LaravelUi5\OData\Edm\Type\EntityType;
23: use LaravelUi5\OData\Edm\Schema;
24: use LaravelUi5\OData\Edm\Vocabularies\Vocabulary;
25: use LaravelUi5\OData\Edm\Vocabularies\VocabularyCatalog;
26: use LaravelUi5\OData\Service\Contracts\EdmBuilderInterface;
27:
28: /**
29: * Mutable accumulator that produces a frozen EdmxInterface (Stage 1).
30: *
31: * Collect schema elements via the fluent API then call build() once.
32: * After build() the builder must not be mutated further.
33: */
34: final class EdmBuilder implements EdmBuilderInterface
35: {
36: private string $namespace = '';
37: private ?string $alias = null;
38: private string $containerName = 'DefaultContainer';
39: private string $version = '4.0';
40: private bool $built = false;
41:
42: /** @var list<ReferenceInterface> */
43: private array $references = [];
44:
45: /** @var list<EntityTypeInterface> */
46: private array $entityTypes = [];
47:
48: /** @var list<ComplexTypeInterface> */
49: private array $complexTypes = [];
50:
51: /** @var array<string, EnumTypeInterface> indexed by qualified name */
52: private array $enumTypes = [];
53:
54: /** @var list<TypeDefinitionInterface> */
55: private array $typeDefinitions = [];
56:
57: /** @var list<FunctionInterface> */
58: private array $functions = [];
59:
60: /** @var list<EntitySetInterface> */
61: private array $entitySets = [];
62:
63: /** @var list<SingletonInterface> */
64: private array $singletons = [];
65:
66: /** @var list<FunctionImportInterface> */
67: private array $functionImports = [];
68:
69: // ── Schema identity ────────────────────────────────────────────────────────
70:
71: public function version(string $version): static
72: {
73: $this->assertNotBuilt();
74: $this->version = $version;
75: return $this;
76: }
77:
78: public function namespace(string $namespace): static
79: {
80: $this->assertNotBuilt();
81: $this->namespace = $namespace;
82: return $this;
83: }
84:
85: public function alias(string $alias): static
86: {
87: $this->assertNotBuilt();
88: $this->alias = $alias;
89: return $this;
90: }
91:
92: public function containerName(string $name): static
93: {
94: $this->assertNotBuilt();
95: $this->containerName = $name;
96: return $this;
97: }
98:
99: // ── References ─────────────────────────────────────────────────────────────
100:
101: public function addReference(ReferenceInterface $reference): static
102: {
103: $this->assertNotBuilt();
104: $this->references[] = $reference;
105: return $this;
106: }
107:
108: public function useVocabulary(Vocabulary $vocabulary): static
109: {
110: $entry = VocabularyCatalog::default()->getEntry($vocabulary->value);
111:
112: if ($entry === null) {
113: throw new \LogicException("Unknown vocabulary: {$vocabulary->value}");
114: }
115:
116: return $this->addReference($entry->toReference());
117: }
118:
119: // ── Types ──────────────────────────────────────────────────────────────────
120:
121: public function addEntityType(EntityTypeInterface $type): static
122: {
123: $this->assertNotBuilt();
124: $this->entityTypes[] = $type;
125:
126: foreach ($type->getDeclaredProperties() as $property) {
127: $propertyType = $property->getType();
128: if ($propertyType instanceof EnumTypeInterface) {
129: $this->addEnumType($propertyType);
130: }
131: }
132:
133: return $this;
134: }
135:
136: public function addComplexType(ComplexTypeInterface $type): static
137: {
138: $this->assertNotBuilt();
139: $this->complexTypes[] = $type;
140: return $this;
141: }
142:
143: public function addEnumType(EnumTypeInterface $type): static
144: {
145: $this->assertNotBuilt();
146:
147: $qualifiedName = $type->getQualifiedName();
148: $existing = $this->enumTypes[$qualifiedName] ?? null;
149:
150: if ($existing === null) {
151: $this->enumTypes[$qualifiedName] = $type;
152: return $this;
153: }
154:
155: if (!self::enumTypesEqual($existing, $type)) {
156: throw new \LogicException(sprintf(
157: 'EnumType "%s" already registered with a different definition. '
158: . 'Two PHP backed enums collide on the EDM short name within this service; '
159: . 'rename one of the source enums or place them in different OData services.',
160: $qualifiedName,
161: ));
162: }
163:
164: return $this;
165: }
166:
167: private static function enumTypesEqual(EnumTypeInterface $a, EnumTypeInterface $b): bool
168: {
169: if ($a->getUnderlyingType() !== $b->getUnderlyingType()) {
170: return false;
171: }
172: if ($a->isFlags() !== $b->isFlags()) {
173: return false;
174: }
175:
176: $aMembers = $a->getMembers();
177: $bMembers = $b->getMembers();
178:
179: if (count($aMembers) !== count($bMembers)) {
180: return false;
181: }
182:
183: $bByName = [];
184: foreach ($bMembers as $member) {
185: $bByName[$member->getName()] = $member->getValue();
186: }
187:
188: foreach ($aMembers as $member) {
189: $name = $member->getName();
190: if (!array_key_exists($name, $bByName) || $bByName[$name] !== $member->getValue()) {
191: return false;
192: }
193: }
194:
195: return true;
196: }
197:
198: public function addTypeDefinition(TypeDefinitionInterface $type): static
199: {
200: $this->assertNotBuilt();
201: $this->typeDefinitions[] = $type;
202: return $this;
203: }
204:
205: public function addFunction(FunctionInterface $function): static
206: {
207: $this->assertNotBuilt();
208: $this->functions[] = $function;
209: return $this;
210: }
211:
212: // ── Container members (no resolvers at this stage) ─────────────────────────
213:
214: public function addEntitySet(EntitySetInterface $set): static
215: {
216: $this->assertNotBuilt();
217: $this->entitySets[] = $set;
218: return $this;
219: }
220:
221: /**
222: * Inject a navigation property into an existing entity type and its entity set.
223: *
224: * Since entity types and sets are immutable, this replaces them with new
225: * instances that include the additional navigation property and binding.
226: * Must be called before build().
227: */
228: public function injectNavigationProperty(
229: string $entityTypeName,
230: NavigationPropertyInterface $navProperty,
231: string $targetEntitySetName,
232: ): static {
233: $this->assertNotBuilt();
234:
235: // Replace the entity type with one that includes the new nav property
236: foreach ($this->entityTypes as $i => $type) {
237: if ($type->getName() === $entityTypeName) {
238: $existingNavProps = $type->getDeclaredNavigationProperties();
239: $existingNavProps[] = $navProperty;
240:
241: $this->entityTypes[$i] = new EntityType(
242: namespace: $type->getQualifiedName() !== $type->getName()
243: ? substr($type->getQualifiedName(), 0, -strlen($type->getName()) - 1)
244: : '',
245: name: $type->getName(),
246: baseType: $type->getBaseType(),
247: isAbstract: $type->isAbstract(),
248: isOpen: $type->isOpen(),
249: hasStream: $type->hasStream(),
250: key: $type->getKey(),
251: declaredProperties: $type->getDeclaredProperties(),
252: declaredNavigationProperties: $existingNavProps,
253: annotations: $type->getAnnotations(),
254: );
255: break;
256: }
257: }
258:
259: // Replace the entity set with one that includes the new nav binding
260: foreach ($this->entitySets as $j => $set) {
261: if ($set->getEntityType()->getName() === $entityTypeName) {
262: $existingBindings = $set->getNavigationPropertyBindings();
263: $existingBindings[] = new NavigationPropertyBinding(
264: $navProperty->getName(),
265: $targetEntitySetName,
266: );
267:
268: $this->entitySets[$j] = new EntitySet(
269: name: $set->getName(),
270: entityType: $this->entityTypes[array_search($entityTypeName, array_map(fn($t) => $t->getName(), $this->entityTypes))],
271: includedInServiceDocument: $set->isIncludedInServiceDocument(),
272: navigationPropertyBindings: $existingBindings,
273: annotations: $set->getAnnotations(),
274: );
275: break;
276: }
277: }
278:
279: return $this;
280: }
281:
282: public function addSingleton(SingletonInterface $singleton): static
283: {
284: $this->assertNotBuilt();
285: $this->singletons[] = $singleton;
286: return $this;
287: }
288:
289: public function addFunctionImport(FunctionImportInterface $import): static
290: {
291: $this->assertNotBuilt();
292: $this->functionImports[] = $import;
293: return $this;
294: }
295:
296: // ── Produce the frozen model ───────────────────────────────────────────────
297:
298: public function build(): EdmxInterface
299: {
300: $this->assertNotBuilt();
301: $this->built = true;
302:
303: $schema = new Schema(
304: namespace: $this->namespace,
305: alias: $this->alias,
306: entityTypes: $this->entityTypes,
307: complexTypes: $this->complexTypes,
308: enumTypes: array_values($this->enumTypes),
309: typeDefinitions: $this->typeDefinitions,
310: functions: $this->functions,
311: );
312:
313: $container = new EntityContainer(
314: name: $this->containerName,
315: entitySets: $this->entitySets,
316: singletons: $this->singletons,
317: functionImports: $this->functionImports,
318: );
319:
320: return new Edmx(
321: version: $this->version,
322: references: $this->references,
323: schemas: [$this->namespace => $schema],
324: entityContainer: $container,
325: );
326: }
327:
328: private function assertNotBuilt(): void
329: {
330: if ($this->built) {
331: throw new \LogicException('EdmBuilder has already been built and must not be mutated.');
332: }
333: }
334: }
335: