1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace LaravelUi5\OData\Service\Discovery;
6:
7: use Illuminate\Database\Eloquent\Model;
8: use Illuminate\Database\Eloquent\Relations\BelongsTo;
9: use Illuminate\Database\Eloquent\Relations\BelongsToMany;
10: use Illuminate\Database\Eloquent\Relations\HasMany;
11: use Illuminate\Database\Eloquent\Relations\HasOne;
12: use Illuminate\Database\Eloquent\Relations\Relation;
13: use Illuminate\Support\Facades\Schema;
14: use Illuminate\Support\Str;
15: use LaravelUi5\OData\Edm\Container\EntitySet;
16: use LaravelUi5\OData\Edm\Container\NavigationPropertyBinding;
17: use LaravelUi5\OData\Edm\EdmPrimitiveType;
18: use LaravelUi5\OData\Edm\Contracts\Type\EntityTypeInterface;
19: use LaravelUi5\OData\Service\Builder\ResolverMapBuilder;
20: use LaravelUi5\OData\Edm\Property\NavigationProperty;
21: use LaravelUi5\OData\Edm\Property\Property;
22: use LaravelUi5\OData\Edm\Type\EntityType;
23: use LaravelUi5\OData\Edm\Type\PrimitiveType;
24: use LaravelUi5\OData\Service\Contracts\EdmBuilderInterface;
25: use LaravelUi5\OData\Service\Discovery\Attributes\ODataEntity;
26: use LaravelUi5\OData\Service\Discovery\Attributes\ODataIgnore;
27: use LaravelUi5\OData\Service\Discovery\Attributes\ODataNavigation;
28: use LaravelUi5\OData\Service\Discovery\Attributes\ODataProperty;
29: use ReflectionClass;
30: use ReflectionMethod;
31:
32: final class ModelDiscovery
33: {
34: /** @var array<class-string<Model>, string> modelClass => short class name placeholder */
35: private array $modelClasses = [];
36:
37: /** @var array<class-string<Model>, EntityType> Pass 1 results (without nav props) */
38: private array $bareTypes = [];
39:
40: /** @var array<class-string<Model>, list<Property>> */
41: private array $properties = [];
42:
43: /** @var array<class-string<Model>, list<Property>> key properties */
44: private array $keys = [];
45:
46: /** @var array<class-string<Model>, string> modelClass => entity type name */
47: private array $typeNames = [];
48:
49: /** @var array<class-string<Model>, string> modelClass => entity set name */
50: private array $entitySetNames = [];
51:
52: /** @var array<string, class-string<Model>> entitySetName => modelClass */
53: private array $entitySetMap = [];
54:
55: /** @var array<class-string<Model>, list<\LaravelUi5\OData\Edm\Contracts\Annotation\AnnotationInterface>> */
56: private array $classAnnotations = [];
57:
58: /** @var list<array{typeName: string, navName: string, targetType: EntityTypeInterface, targetSetName: string}> */
59: private array $virtualExpands = [];
60:
61: private readonly AttributeReader $attributeReader;
62:
63: public function __construct()
64: {
65: $this->attributeReader = new AttributeReader();
66: }
67:
68: public function add(string $modelClass): void
69: {
70: $this->modelClasses[$modelClass] = true;
71: }
72:
73: /**
74: * Register a virtual navigation property to be added to a discovered entity type.
75: *
76: * Called before apply() so that Pass 2 can include the virtual nav prop
77: * alongside discovered Eloquent relations.
78: */
79: public function addVirtualExpand(
80: string $typeName,
81: string $navName,
82: EntityTypeInterface $targetType,
83: string $targetSetName,
84: ): void {
85: $this->virtualExpands[] = [
86: 'typeName' => $typeName,
87: 'navName' => $navName,
88: 'targetType' => $targetType,
89: 'targetSetName' => $targetSetName,
90: ];
91: }
92:
93: /**
94: * Two-pass discovery: build types, then wire navigation properties.
95: */
96: public function apply(EdmBuilderInterface $builder, string $namespace): void
97: {
98: // Pass 1: discover structural properties and bare entity types
99: foreach (array_keys($this->modelClasses) as $modelClass) {
100: $this->discoverModel($modelClass, $namespace);
101: }
102:
103: // Pass 2: discover relationships, rebuild types with nav props, register on builder
104: foreach (array_keys($this->modelClasses) as $modelClass) {
105: $ref = new ReflectionClass($modelClass);
106: $model = $ref->newInstanceWithoutConstructor();
107: $navProps = $this->discoverRelationships($model, $ref, $namespace);
108:
109: // Append virtual navigation properties targeting this entity type
110: $typeName = $this->typeNames[$modelClass];
111: $virtualBindings = [];
112:
113: foreach ($this->virtualExpands as $ve) {
114: if ($ve['typeName'] === $typeName) {
115: $navProps[] = new NavigationProperty(
116: name: $ve['navName'],
117: targetType: $ve['targetType'],
118: isCollection: true,
119: );
120: $virtualBindings[] = new NavigationPropertyBinding(
121: $ve['navName'],
122: $ve['targetSetName'],
123: );
124: }
125: }
126:
127: $entityType = new EntityType(
128: namespace: $namespace,
129: name: $typeName,
130: key: $this->keys[$modelClass],
131: declaredProperties: $this->properties[$modelClass],
132: declaredNavigationProperties: $navProps,
133: annotations: $this->classAnnotations[$modelClass] ?? [],
134: );
135:
136: $navBindings = [];
137: foreach ($navProps as $navProp) {
138: $targetName = $navProp->getTargetType()->getName();
139: // Find the entity set for this target type (Eloquent relations)
140: foreach ($this->typeNames as $mc => $tn) {
141: if ($tn === $targetName && isset($this->entitySetNames[$mc])) {
142: $navBindings[] = new NavigationPropertyBinding(
143: $navProp->getName(),
144: $this->entitySetNames[$mc],
145: );
146: break;
147: }
148: }
149: }
150:
151: // Add virtual expand bindings (target set is a custom entity set, not discovered)
152: $navBindings = array_merge($navBindings, $virtualBindings);
153:
154: $entitySet = new EntitySet(
155: name: $this->entitySetNames[$modelClass],
156: entityType: $entityType,
157: navigationPropertyBindings: $navBindings,
158: );
159:
160: $builder->addEntityType($entityType);
161: $builder->addEntitySet($entitySet);
162:
163: $this->entitySetMap[$this->entitySetNames[$modelClass]] = $modelClass;
164: }
165: }
166:
167: /**
168: * @return array<string, class-string<Model>> entitySetName => modelClass
169: */
170: public function getEntitySetMap(): array
171: {
172: return $this->entitySetMap;
173: }
174:
175: /**
176: * @return list<string> discovered entity type names
177: */
178: public function getDiscoveredTypeNames(): array
179: {
180: return array_values($this->typeNames);
181: }
182:
183: /**
184: * Register discovered models as EloquentBindings on a ResolverMapBuilder.
185: *
186: * Called after apply() so that entitySetMap is populated.
187: */
188: public function registerOnMap(ResolverMapBuilder $map): void
189: {
190: $container = $map->getEdmx()->getEntityContainer();
191:
192: foreach ($this->entitySetMap as $entitySetName => $modelClass) {
193: $set = $container->getEntitySet($entitySetName);
194: if ($set !== null) {
195: $map->eloquent($set, $modelClass);
196: }
197: }
198: }
199:
200: /**
201: * Pass 1: inspect columns, casts, key — build properties and bare type.
202: */
203: private function discoverModel(string $modelClass, string $namespace): void
204: {
205: $ref = new ReflectionClass($modelClass);
206: $model = $ref->newInstanceWithoutConstructor();
207:
208: // Resolve names (with attribute overrides)
209: $entityAttr = $this->readEntityAttribute($ref);
210: $typeName = $entityAttr?->name ?? $ref->getShortName();
211: $entitySetName = $entityAttr?->entitySet ?? Str::plural($typeName);
212:
213: $this->typeNames[$modelClass] = $typeName;
214: $this->entitySetNames[$modelClass] = $entitySetName;
215:
216: // Discover columns
217: $table = $model->getTable();
218: $columns = Schema::getColumns($table);
219: $casts = $model->getCasts();
220: $keyName = $model->getKeyName();
221:
222: $properties = [];
223: $keyProps = [];
224:
225: foreach ($columns as $column) {
226: $colName = $column['name'];
227:
228: // Check for #[ODataIgnore] on the model property (if it exists)
229: if ($ref->hasProperty($colName) && $this->hasIgnoreAttribute($ref->getProperty($colName))) {
230: continue;
231: }
232:
233: // Determine OData type: casts override DB type
234: $primitiveType = isset($casts[$colName])
235: ? (self::mapCastType($casts[$colName]) ?? self::mapColumnType($column['type_name']))
236: : self::mapColumnType($column['type_name']);
237:
238: // Check for #[ODataProperty] overrides
239: $propAttr = $ref->hasProperty($colName)
240: ? $this->readPropertyAttribute($ref->getProperty($colName))
241: : null;
242:
243: if ($propAttr?->type !== null) {
244: $enumCase = EdmPrimitiveType::tryFrom($propAttr->type);
245: if ($enumCase !== null) {
246: $primitiveType = $enumCase;
247: }
248: }
249:
250: $propName = $propAttr?->name ?? $colName;
251:
252: // Read vocabulary annotations from PHP attributes on the model property
253: $propAnnotations = $ref->hasProperty($colName)
254: ? $this->attributeReader->readProperty($ref->getProperty($colName))
255: : [];
256:
257: $property = new Property(
258: name: $propName,
259: type: new PrimitiveType($primitiveType),
260: annotations: $propAnnotations,
261: );
262:
263: $properties[] = $property;
264:
265: if ($colName === $keyName) {
266: $keyProps[] = $property;
267: }
268: }
269:
270: $this->properties[$modelClass] = $properties;
271: $this->keys[$modelClass] = $keyProps;
272:
273: // Read class-level vocabulary annotations for the final EntityType in Pass 2
274: $this->classAnnotations[$modelClass] = $this->attributeReader->readClass($ref);
275:
276: // Store bare type for nav prop target references in Pass 2
277: $this->bareTypes[$modelClass] = new EntityType(
278: namespace: $namespace,
279: name: $typeName,
280: key: $keyProps,
281: declaredProperties: $properties,
282: annotations: $this->classAnnotations[$modelClass],
283: );
284: }
285:
286: /**
287: * Pass 2: discover relationships and build navigation properties.
288: *
289: * @return list<NavigationProperty>
290: */
291: private function discoverRelationships(Model $model, ReflectionClass $ref, string $namespace): array
292: {
293: $navProps = [];
294:
295: foreach ($ref->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
296: // Only own methods, no magic, no parameters
297: if ($method->class !== $ref->getName()) {
298: continue;
299: }
300: if (str_starts_with($method->getName(), '__')) {
301: continue;
302: }
303: if ($method->getNumberOfRequiredParameters() > 0) {
304: continue;
305: }
306:
307: // Check for #[ODataIgnore]
308: if ($method->getAttributes(ODataIgnore::class) !== []) {
309: continue;
310: }
311:
312: // Try calling the method to get a Relation
313: try {
314: $result = $method->invoke($model);
315: } catch (\Throwable) {
316: continue;
317: }
318:
319: if (!$result instanceof Relation) {
320: continue;
321: }
322:
323: // Only support specific relation types
324: $isCollection = match (true) {
325: $result instanceof HasMany, $result instanceof BelongsToMany => true,
326: $result instanceof BelongsTo, $result instanceof HasOne => false,
327: default => null,
328: };
329:
330: if ($isCollection === null) {
331: continue;
332: }
333:
334: $relatedClass = get_class($result->getRelated());
335:
336: // Only wire if the target model was also discovered
337: if (!isset($this->bareTypes[$relatedClass])) {
338: continue;
339: }
340:
341: // Check for #[ODataNavigation] name override
342: $navAttrs = $method->getAttributes(ODataNavigation::class);
343: $navName = $navAttrs !== []
344: ? ($navAttrs[0]->newInstance()->name ?? $method->getName())
345: : $method->getName();
346:
347: $navProps[] = new NavigationProperty(
348: name: $navName,
349: targetType: $this->bareTypes[$relatedClass],
350: isCollection: $isCollection,
351: );
352: }
353:
354: return $navProps;
355: }
356:
357: private static function mapColumnType(string $typeName): EdmPrimitiveType
358: {
359: return match (true) {
360: in_array($typeName, ['string', 'varchar', 'char', 'text', 'tinytext', 'mediumtext', 'longtext', 'enum', 'set'], true)
361: => EdmPrimitiveType::String,
362: in_array($typeName, ['integer', 'int', 'tinyint', 'smallint', 'mediumint'], true)
363: => EdmPrimitiveType::Int32,
364: in_array($typeName, ['bigint'], true)
365: => EdmPrimitiveType::Int64,
366: in_array($typeName, ['float', 'double', 'real'], true)
367: => EdmPrimitiveType::Double,
368: in_array($typeName, ['decimal', 'numeric'], true)
369: => EdmPrimitiveType::Decimal,
370: in_array($typeName, ['boolean', 'bool'], true)
371: => EdmPrimitiveType::Boolean,
372: $typeName === 'date'
373: => EdmPrimitiveType::Date,
374: in_array($typeName, ['datetime', 'timestamp'], true)
375: => EdmPrimitiveType::DateTimeOffset,
376: $typeName === 'time'
377: => EdmPrimitiveType::TimeOfDay,
378: in_array($typeName, ['blob', 'binary', 'varbinary'], true)
379: => EdmPrimitiveType::Binary,
380: in_array($typeName, ['json', 'jsonb'], true)
381: => EdmPrimitiveType::String,
382: in_array($typeName, ['uuid', 'guid'], true)
383: => EdmPrimitiveType::Guid,
384: default => EdmPrimitiveType::String,
385: };
386: }
387:
388: private static function mapCastType(string $cast): ?EdmPrimitiveType
389: {
390: $baseCast = str_contains($cast, ':') ? substr($cast, 0, (int) strpos($cast, ':')) : $cast;
391:
392: return match ($baseCast) {
393: 'integer', 'int' => EdmPrimitiveType::Int32,
394: 'float', 'double' => EdmPrimitiveType::Double,
395: 'decimal' => EdmPrimitiveType::Decimal,
396: 'boolean', 'bool' => EdmPrimitiveType::Boolean,
397: 'date' => EdmPrimitiveType::Date,
398: 'datetime', 'timestamp', 'immutable_date', 'immutable_datetime'
399: => EdmPrimitiveType::DateTimeOffset,
400: 'string' => EdmPrimitiveType::String,
401: 'array', 'json', 'collection', 'object'
402: => EdmPrimitiveType::String,
403: default => null,
404: };
405: }
406:
407: private function readEntityAttribute(ReflectionClass $ref): ?ODataEntity
408: {
409: $attrs = $ref->getAttributes(ODataEntity::class);
410: return $attrs !== [] ? $attrs[0]->newInstance() : null;
411: }
412:
413: private function readPropertyAttribute(\ReflectionProperty $prop): ?ODataProperty
414: {
415: $attrs = $prop->getAttributes(ODataProperty::class);
416: return $attrs !== [] ? $attrs[0]->newInstance() : null;
417: }
418:
419: private function hasIgnoreAttribute(\ReflectionProperty $prop): bool
420: {
421: return $prop->getAttributes(ODataIgnore::class) !== [];
422: }
423: }
424: