1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace LaravelUi5\OData;
6:
7: use LaravelUi5\OData\Edm\Container\EntitySet;
8: use LaravelUi5\OData\Service\Builder\EdmBuilder;
9: use LaravelUi5\OData\Service\Builder\ResolverMapBuilder;
10: use LaravelUi5\OData\Service\Builder\RuntimeSchemaBuilder;
11: use LaravelUi5\OData\Service\Cache\EdmxLoader;
12: use LaravelUi5\OData\Service\Cache\ResolverMapLoader;
13: use LaravelUi5\OData\Service\Contracts\CustomEntitySetInterface;
14: use LaravelUi5\OData\Service\Contracts\EdmBuilderInterface;
15: use LaravelUi5\OData\Service\Contracts\ODataServiceInterface;
16: use LaravelUi5\OData\Service\Contracts\RuntimeSchemaBuilderInterface;
17: use LaravelUi5\OData\Service\Contracts\RuntimeSchemaInterface;
18: use LaravelUi5\OData\Edm\Property\NavigationProperty;
19: use LaravelUi5\OData\Service\Contracts\VirtualExpandResolverInterface;
20: use LaravelUi5\OData\Service\Discovery\ModelDiscovery;
21: use LaravelUi5\OData\Service\Resolver\ResolverMap;
22:
23: /**
24: * Base OData service implementation.
25: *
26: * Subclasses override configure(), registerBindings(), and bindFunctions()
27: * to declare their EDM structure, wire entity set resolvers, and bind
28: * function/singleton resolvers. The runtime schema is built lazily on the
29: * first call to schema() and cached for the lifetime of the instance.
30: *
31: * Example:
32: *
33: * class PartnerService extends ODataService
34: * {
35: * public function serviceUri(): string { return 'partners'; }
36: * public function namespace(): string { return 'Partners.Data'; }
37: *
38: * protected function configure(EdmBuilderInterface $b): EdmBuilderInterface
39: * {
40: * $this->discoverModel(Partner::class);
41: * return $b->namespace('Partners.Data');
42: * }
43: *
44: * protected function registerBindings(ResolverMapBuilder $map): void
45: * {
46: * // Discovered models are auto-registered. Add manual bindings:
47: * $c = $map->getEdmx()->getEntityContainer();
48: * $map->sql($c->getEntitySet('ValueHelp'), 'value_help_view');
49: * }
50: *
51: * protected function bindFunctions(RuntimeSchemaBuilderInterface $b): void
52: * {
53: * // Bind function imports and singletons here.
54: * }
55: * }
56: */
57: class ODataService implements ODataServiceInterface
58: {
59: private ?RuntimeSchemaInterface $cachedSchema = null;
60: private ?ModelDiscovery $discovery = null;
61:
62: /** @var list<class-string<CustomEntitySetInterface>> */
63: private array $customEntitySets = [];
64:
65: public function __construct(
66: private readonly string $serviceUriValue = '',
67: private readonly string $namespaceValue = '',
68: ) {}
69:
70: // ── ODataServiceInterface ───────────────────────────────────────────────
71:
72: public function serviceUri(): string
73: {
74: return $this->serviceUriValue;
75: }
76:
77: public function namespace(): string
78: {
79: return $this->namespaceValue;
80: }
81:
82: public function cachedMetadataXMLPath(): ?string
83: {
84: return null;
85: }
86:
87: public function endpoint(): string
88: {
89: $prefix = rtrim(config('odata.prefix', 'odata'), '/');
90: $uri = $this->serviceUri();
91: $route = ($uri === '') ? $prefix : $prefix . '/' . $uri;
92:
93: return url($route) . '/';
94: }
95:
96: public function route(): string
97: {
98: $prefix = rtrim(config('odata.prefix', 'odata'), '/');
99: $uri = $this->serviceUri();
100:
101: return ($uri === '') ? $prefix : $prefix . '/' . $uri;
102: }
103:
104: public function schema(): RuntimeSchemaInterface
105: {
106: if ($this->cachedSchema === null) {
107: // ── Warm path: cached Edmx + ResolverMap ────────────────────
108: $edmx = EdmxLoader::forService($this);
109: $resolverMap = ResolverMapLoader::forService($this);
110:
111: if ($edmx !== null && $resolverMap !== null) {
112: $runtimeBuilder = new RuntimeSchemaBuilder($edmx);
113: $resolverMap->applyTo($runtimeBuilder);
114: $this->bindFunctions($runtimeBuilder);
115: $this->cachedSchema = $runtimeBuilder->build();
116:
117: return $this->cachedSchema;
118: }
119:
120: // ── Cold path: build from configure() + discovery ───────────
121: $builder = (new EdmBuilder())->version(config('odata.version', '4.0'));
122: $builder = $this->configure($builder);
123:
124: // Register custom entity types first so discovery and the builder
125: // can wire virtual navigation properties.
126: $this->applyCustomEntitySets($builder);
127:
128: if ($this->discovery !== null) {
129: $this->applyVirtualExpandsToDiscovery();
130: $this->discovery->apply($builder, $this->namespace());
131: }
132:
133: // Wire virtual expands on entity types NOT managed by discovery
134: // (manually defined types). Discovery-managed types are handled
135: // by applyVirtualExpandsToDiscovery() above.
136: $this->applyVirtualExpandsToBuilder($builder);
137:
138: $edmx = $builder->build();
139:
140: // Build the ResolverMap from registerBindings() + discovery + custom entity sets
141: $mapBuilder = new ResolverMapBuilder($edmx);
142:
143: if ($this->discovery !== null) {
144: $this->discovery->registerOnMap($mapBuilder);
145: }
146:
147: $this->applyCustomEntitySetBindings($mapBuilder);
148: $this->registerBindings($mapBuilder);
149: $resolverMap = $mapBuilder->build();
150:
151: $runtimeBuilder = new RuntimeSchemaBuilder($edmx);
152: $resolverMap->applyTo($runtimeBuilder);
153: $this->bindFunctions($runtimeBuilder);
154: $this->cachedSchema = $runtimeBuilder->build();
155: }
156:
157: return $this->cachedSchema;
158: }
159:
160: /**
161: * Return the ResolverMap for this service (used by odata:cache).
162: *
163: * Forces a cold-path schema build if not already cached.
164: */
165: public function resolverMap(): ResolverMap
166: {
167: // Ensure schema() has run so the map is built
168: $this->schema();
169:
170: // Rebuild the map (schema() doesn't store it separately)
171: $edmx = $this->cachedSchema->getEdmx();
172: $mapBuilder = new ResolverMapBuilder($edmx);
173:
174: if ($this->discovery !== null) {
175: $this->discovery->registerOnMap($mapBuilder);
176: }
177:
178: $this->applyCustomEntitySetBindings($mapBuilder);
179: $this->registerBindings($mapBuilder);
180:
181: return $mapBuilder->build();
182: }
183:
184: // ── Extension hooks ─────────────────────────────────────────────────────
185:
186: /**
187: * Populate the EdmBuilder with entity types, entity sets, functions, etc.
188: *
189: * Subclasses must override this and call $builder->namespace() before
190: * returning. Use $this->discoverModel() to auto-discover Eloquent models.
191: */
192: protected function configure(EdmBuilderInterface $builder): EdmBuilderInterface
193: {
194: return $builder->namespace($this->namespace());
195: }
196:
197: /**
198: * Register entity set resolver bindings.
199: *
200: * Discovered models are already registered as EloquentBindings.
201: * Override this to add manual bindings for SQL views, custom sources, etc.
202: * Bindings are serialized by odata:cache for the warm boot path.
203: */
204: protected function registerBindings(ResolverMapBuilder $map): void
205: {
206: // Default: no manual bindings beyond discovered models.
207: }
208:
209: /**
210: * Bind function import and singleton resolvers.
211: *
212: * These are not cached — they run on every boot (both cold and warm).
213: * Override this to bind FunctionResolverInterface and SingletonResolverInterface.
214: */
215: protected function bindFunctions(RuntimeSchemaBuilderInterface $builder): void
216: {
217: // Default: no functions or singletons.
218: }
219:
220: /**
221: * Register an Eloquent model for auto-discovery.
222: *
223: * Call this in configure() to have the model's columns, key, and
224: * relationships automatically mapped to OData entity types and sets.
225: * An EloquentBinding is auto-registered in the ResolverMap.
226: */
227: protected function discoverModel(string $modelClass): static
228: {
229: $this->discovery ??= new ModelDiscovery();
230: $this->discovery->add($modelClass);
231:
232: return $this;
233: }
234:
235: /**
236: * Register a custom entity set with colocated type definition and resolver.
237: *
238: * Call this in configure(). The entity type and set are added to the Edm,
239: * and a CustomBinding is auto-registered in the ResolverMap — no manual
240: * registerBindings() wiring needed.
241: *
242: * @param class-string<CustomEntitySetInterface> $resolverClass
243: */
244: protected function discoverCustomEntitySet(string $resolverClass): static
245: {
246: $this->customEntitySets[] = $resolverClass;
247:
248: return $this;
249: }
250:
251: /**
252: * Apply accumulated custom entity set registrations to the builder.
253: */
254: private function applyCustomEntitySets(EdmBuilderInterface $builder): void
255: {
256: $namespace = $this->namespace();
257:
258: foreach ($this->customEntitySets as $resolverClass) {
259: $instance = new $resolverClass();
260: $entityType = $instance->entityType($namespace);
261: $setName = $instance->entitySetName();
262:
263: $builder->addEntityType($entityType);
264: $builder->addEntitySet(new EntitySet($setName, $entityType));
265: }
266: }
267:
268: /**
269: * Pass virtual expand declarations to discovery so it can wire
270: * navigation properties on parent entity types in Pass 2.
271: */
272: private function applyVirtualExpandsToDiscovery(): void
273: {
274: $namespace = $this->namespace();
275:
276: foreach ($this->customEntitySets as $resolverClass) {
277: $instance = new $resolverClass();
278:
279: if (!($instance instanceof VirtualExpandResolverInterface)) {
280: continue;
281: }
282:
283: $targetType = $instance->entityType($namespace);
284: $targetSetName = $instance->entitySetName();
285:
286: foreach ($instance->expandsOn() as $parentTypeName => $navName) {
287: $this->discovery->addVirtualExpand(
288: $parentTypeName,
289: $navName,
290: $targetType,
291: $targetSetName,
292: );
293: }
294: }
295: }
296:
297: /**
298: * Wire virtual expands directly on the builder for entity types that
299: * are NOT managed by discovery (manually defined in configure()).
300: *
301: * Discovery-managed types are handled by applyVirtualExpandsToDiscovery().
302: * This method skips types that discovery already knows about to avoid
303: * duplicate navigation properties.
304: */
305: private function applyVirtualExpandsToBuilder(EdmBuilderInterface $builder): void
306: {
307: $namespace = $this->namespace();
308: $discoveredTypes = $this->discovery?->getDiscoveredTypeNames() ?? [];
309:
310: foreach ($this->customEntitySets as $resolverClass) {
311: $instance = new $resolverClass();
312:
313: if (!($instance instanceof VirtualExpandResolverInterface)) {
314: continue;
315: }
316:
317: $targetType = $instance->entityType($namespace);
318: $targetSetName = $instance->entitySetName();
319:
320: foreach ($instance->expandsOn() as $parentTypeName => $navName) {
321: // Skip types managed by discovery — they're handled in Pass 2
322: if (in_array($parentTypeName, $discoveredTypes, true)) {
323: continue;
324: }
325:
326: $builder->injectNavigationProperty(
327: $parentTypeName,
328: new NavigationProperty(
329: name: $navName,
330: targetType: $targetType,
331: isCollection: true,
332: ),
333: $targetSetName,
334: );
335: }
336: }
337: }
338:
339: /**
340: * Register custom entity set bindings on the resolver map.
341: */
342: private function applyCustomEntitySetBindings(ResolverMapBuilder $map): void
343: {
344: $container = $map->getEdmx()->getEntityContainer();
345:
346: foreach ($this->customEntitySets as $resolverClass) {
347: $instance = new $resolverClass();
348: $setName = $instance->entitySetName();
349: $set = $container->getEntitySet($setName);
350:
351: if ($set !== null) {
352: $map->custom($set, $resolverClass);
353: }
354: }
355: }
356: }
357: