Skip to content

Commit faa3035

Browse files
leeoptimaclaude
andcommitted
Implement deduplicated schema format with definitions
Schema now stores each related model once in a 'definitions' object instead of duplicating it inline for every relationship. - Added getDeduplicatedSchema() method - Relationships reference models by class name - Definitions contain unique model schemas - 93% size reduction (1960KB -> 143KB for Associate model) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 285ce95 commit faa3035

File tree

1 file changed

+209
-71
lines changed

1 file changed

+209
-71
lines changed

src/Services/ModelSchemaService.php

Lines changed: 209 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -93,112 +93,250 @@ public function __construct(
9393
/**
9494
* Get the complete schema for a model class.
9595
*
96-
* When reference caching is enabled (default), only depth 1 schemas are cached
97-
* individually. Deeper schemas are composed on-the-fly by linking to cached
98-
* depth 1 schemas, dramatically reducing cache size and duplication.
96+
* Returns a deduplicated schema where each related model appears only once
97+
* in a 'definitions' object. Relationships reference these definitions
98+
* instead of duplicating the schema inline.
9999
*/
100100
public function getSchema(string $modelClass, int $maxDepth = 2): ?array
101101
{
102102
if (! $this->isValidModel($modelClass)) {
103103
return null;
104104
}
105105

106-
if ($this->cacheTtl <= 0) {
107-
return $this->buildSchema($modelClass, 0, $maxDepth, []);
108-
}
106+
// Always use deduplicated format to minimize payload size
107+
return $this->getDeduplicatedSchema($modelClass, $maxDepth);
108+
}
109109

110-
// Use reference-based caching: cache depth 1 schemas, compose deeper on-the-fly
111-
if ($this->useReferenceCaching && $maxDepth > 1) {
112-
return $this->getSchemaWithReferences($modelClass, $maxDepth);
110+
/**
111+
* Get schema in deduplicated format with definitions.
112+
*
113+
* Returns structure:
114+
* [
115+
* 'model' => 'App\Models\User',
116+
* 'columns' => [...],
117+
* 'relationships' => [
118+
* 'posts' => ['name' => 'posts', 'related_model' => 'App\Models\Post', ...]
119+
* ],
120+
* 'definitions' => [
121+
* 'App\Models\Post' => ['model' => '...', 'columns' => [...], 'relationships' => [...]]
122+
* ]
123+
* ]
124+
*/
125+
public function getDeduplicatedSchema(string $modelClass, int $maxDepth = 2): ?array
126+
{
127+
if (! $this->isValidModel($modelClass)) {
128+
return null;
113129
}
114130

115-
return Cache::remember(
116-
$this->getCacheKey($modelClass, $maxDepth),
117-
$this->cacheTtl,
118-
fn () => $this->buildSchema($modelClass, 0, $maxDepth, [])
119-
);
131+
$definitions = [];
132+
$schema = $this->buildSchemaCollectingDefinitions($modelClass, 0, $maxDepth, [], $definitions);
133+
$schema['definitions'] = $definitions;
134+
135+
return $schema;
120136
}
121137

122138
/**
123-
* Get schema by composing from cached depth 1 schemas.
139+
* Build schema while collecting unique model definitions.
124140
*
125-
* This avoids storing duplicate relationship schemas in cache.
126-
* Each model's base schema is cached once, then composed for deeper requests.
127-
*/
128-
protected function getSchemaWithReferences(string $modelClass, int $maxDepth): array
129-
{
130-
// Get the base schema at depth 1 (cached)
131-
$baseSchema = $this->getBaseSchema($modelClass);
141+
* Instead of embedding full schemas in relationships, we collect each
142+
* unique model's schema in $definitions and relationships just reference them.
143+
*/
144+
protected function buildSchemaCollectingDefinitions(
145+
string $modelClass,
146+
int $depth,
147+
int $maxDepth,
148+
array $visited,
149+
array &$definitions
150+
): array {
151+
if (in_array($modelClass, $visited)) {
152+
return [
153+
'model' => $modelClass,
154+
'model_short' => class_basename($modelClass),
155+
'circular' => true,
156+
];
157+
}
158+
159+
$model = new $modelClass;
160+
$newVisited = [...$visited, $modelClass];
132161

133-
if ($maxDepth === 1 || empty($baseSchema['relationships'])) {
134-
return $baseSchema;
162+
$schema = [
163+
'model' => $modelClass,
164+
'model_short' => class_basename($modelClass),
165+
'table' => $model->getTable(),
166+
'columns' => $this->getColumns($model),
167+
'relationships' => [],
168+
];
169+
170+
if ($depth < $maxDepth) {
171+
$schema['relationships'] = $this->getRelationshipsWithDefinitions(
172+
$model,
173+
$modelClass,
174+
$depth,
175+
$maxDepth,
176+
$newVisited,
177+
$definitions
178+
);
135179
}
136180

137-
// Compose deeper levels by linking to cached depth 1 schemas
138-
return $this->composeSchemaWithDepth($baseSchema, 1, $maxDepth, [$modelClass]);
181+
return $schema;
139182
}
140183

141184
/**
142-
* Get the base (depth 1) schema for a model, from cache or freshly built.
185+
* Get relationships, adding related model schemas to definitions instead of inline.
143186
*/
144-
protected function getBaseSchema(string $modelClass): array
145-
{
146-
return Cache::remember(
147-
$this->getCacheKey($modelClass, 1),
148-
$this->cacheTtl,
149-
fn () => $this->buildSchema($modelClass, 0, 1, [])
150-
);
187+
protected function getRelationshipsWithDefinitions(
188+
Model $model,
189+
string $modelClass,
190+
int $depth,
191+
int $maxDepth,
192+
array $visited,
193+
array &$definitions
194+
): array {
195+
$reflection = new ReflectionClass($model);
196+
197+
return collect($reflection->getMethods(ReflectionMethod::IS_PUBLIC))
198+
->filter(fn (ReflectionMethod $method) => $method->class === $modelClass)
199+
->reject(fn (ReflectionMethod $method) => $method->getNumberOfRequiredParameters() > 0)
200+
->reject(fn (ReflectionMethod $method) => $this->isExcludedMethod($method->getName()))
201+
->mapWithKeys(function (ReflectionMethod $method) use ($model, $depth, $maxDepth, $visited, &$definitions) {
202+
return $this->parseRelationshipWithDefinitions(
203+
$model,
204+
$method,
205+
$depth,
206+
$maxDepth,
207+
$visited,
208+
$definitions
209+
);
210+
})
211+
->filter()
212+
->all();
151213
}
152214

153215
/**
154-
* Compose a schema with additional depth by linking relationship schemas.
216+
* Parse a relationship method, storing related schema in definitions.
155217
*/
156-
protected function composeSchemaWithDepth(array $schema, int $currentDepth, int $maxDepth, array $visited): array
157-
{
158-
if ($currentDepth >= $maxDepth || empty($schema['relationships'])) {
159-
return $schema;
218+
protected function parseRelationshipWithDefinitions(
219+
Model $model,
220+
ReflectionMethod $method,
221+
int $depth,
222+
int $maxDepth,
223+
array $visited,
224+
array &$definitions
225+
): array {
226+
// Check return type hint first
227+
$returnType = $method->getReturnType();
228+
229+
if ($returnType instanceof ReflectionNamedType && in_array($returnType->getName(), self::RELATION_TYPES)) {
230+
$info = $this->buildRelationshipWithDefinitions($model, $method, $depth, $maxDepth, $visited, $definitions);
231+
232+
return $info ? [$method->getName() => $info] : [];
160233
}
161234

162-
$schema['relationships'] = collect($schema['relationships'])
163-
->map(function (array $relationInfo) use ($currentDepth, $maxDepth, $visited) {
164-
// Skip if no related model or already has schema
165-
if (! isset($relationInfo['related_model'])) {
166-
return $relationInfo;
167-
}
235+
// If method has a non-relation return type, skip invoking it
236+
if ($returnType instanceof ReflectionNamedType && ! in_array($returnType->getName(), ['mixed', 'void', 'null', 'static', 'self'])) {
237+
return [];
238+
}
239+
240+
// Try invoking the method (only for methods without type hints)
241+
try {
242+
$result = $method->invoke($model);
168243

169-
$relatedModel = $relationInfo['related_model'];
244+
if ($result instanceof Relation) {
245+
return [$method->getName() => $this->buildRelationshipInfoWithDefinitions(
246+
$result,
247+
$method->getName(),
248+
$depth,
249+
$maxDepth,
250+
$visited,
251+
$definitions
252+
)];
253+
}
254+
} catch (\Throwable) {
255+
// Method threw an exception, skip it
256+
}
170257

171-
// Check for circular reference
172-
if (in_array($relatedModel, $visited)) {
173-
$relationInfo['schema'] = [
174-
'model' => $relatedModel,
175-
'model_short' => class_basename($relatedModel),
176-
'circular' => true,
177-
];
258+
return [];
259+
}
178260

179-
return $relationInfo;
180-
}
261+
/**
262+
* Build relationship info by invoking the method, storing related schema in definitions.
263+
*/
264+
protected function buildRelationshipWithDefinitions(
265+
Model $model,
266+
ReflectionMethod $method,
267+
int $depth,
268+
int $maxDepth,
269+
array $visited,
270+
array &$definitions
271+
): ?array {
272+
try {
273+
$result = $method->invoke($model);
181274

182-
// Get the related model's base schema (from cache)
183-
$relatedSchema = $this->getBaseSchema($relatedModel);
275+
return $result instanceof Relation
276+
? $this->buildRelationshipInfoWithDefinitions($result, $method->getName(), $depth, $maxDepth, $visited, $definitions)
277+
: null;
278+
} catch (\Throwable) {
279+
$typeName = $method->getReturnType()->getName();
184280

185-
// Recursively compose if we need more depth
186-
if ($currentDepth + 1 < $maxDepth) {
187-
$relatedSchema = $this->composeSchemaWithDepth(
188-
$relatedSchema,
189-
$currentDepth + 1,
190-
$maxDepth,
191-
[...$visited, $relatedModel]
192-
);
193-
}
281+
return [
282+
'name' => $method->getName(),
283+
'type' => class_basename($typeName),
284+
'type_full' => $typeName,
285+
'is_collection' => in_array($typeName, self::COLLECTION_RELATIONS),
286+
];
287+
}
288+
}
289+
290+
/**
291+
* Build relationship info, adding related model's schema to definitions (once).
292+
*/
293+
protected function buildRelationshipInfoWithDefinitions(
294+
Relation $relation,
295+
string $name,
296+
int $depth,
297+
int $maxDepth,
298+
array $visited,
299+
array &$definitions
300+
): array {
301+
$relatedModel = get_class($relation->getRelated());
302+
$typeName = get_class($relation);
194303

195-
$relationInfo['schema'] = $relatedSchema;
304+
$info = [
305+
'name' => $name,
306+
'type' => class_basename($typeName),
307+
'type_full' => $typeName,
308+
'related_model' => $relatedModel,
309+
'related_model_short' => class_basename($relatedModel),
310+
'is_collection' => in_array($typeName, self::COLLECTION_RELATIONS),
311+
];
196312

197-
return $relationInfo;
198-
})
199-
->all();
313+
// Add related model to definitions if not already there and not circular
314+
if ($depth + 1 <= $maxDepth && ! in_array($relatedModel, $visited)) {
315+
if (! isset($definitions[$relatedModel])) {
316+
// Build and store the related model's schema in definitions
317+
$definitions[$relatedModel] = $this->buildSchemaCollectingDefinitions(
318+
$relatedModel,
319+
$depth + 1,
320+
$maxDepth,
321+
$visited,
322+
$definitions
323+
);
324+
}
325+
}
200326

201-
return $schema;
327+
return $info;
328+
}
329+
330+
/**
331+
* Get the base (depth 1) schema for a model, from cache or freshly built.
332+
*/
333+
protected function getBaseSchema(string $modelClass): array
334+
{
335+
return Cache::remember(
336+
$this->getCacheKey($modelClass, 1),
337+
$this->cacheTtl,
338+
fn () => $this->buildSchema($modelClass, 0, 1, [])
339+
);
202340
}
203341

204342
/**

0 commit comments

Comments
 (0)