Skip to content

Commit 350ae8d

Browse files
authored
Merge pull request #122 from wol-soft/refImprovements
Ref improvements
2 parents f751ec6 + 0eeba51 commit 350ae8d

39 files changed

Lines changed: 1004 additions & 23 deletions

File tree

src/Model/SchemaDefinition/SchemaDefinitionDictionary.php

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@
1616
*/
1717
class SchemaDefinitionDictionary extends ArrayObject
1818
{
19-
/** @var Schema[] */
19+
/** @var Schema[] keyed by canonical file path */
2020
private array $parsedExternalFileSchemas = [];
2121

22+
/** @var string[] maps raw ref string → canonical file path */
23+
private array $rawRefToCanonical = [];
24+
2225
/**
2326
* SchemaDefinitionDictionary constructor.
2427
*/
@@ -111,8 +114,9 @@ public function getDefinition(string $key, SchemaProcessor $schemaProcessor, arr
111114
$externalKey = '';
112115
}
113116

114-
if (array_key_exists($jsonSchemaFile, $this->parsedExternalFileSchemas)) {
115-
return $this->parsedExternalFileSchemas[$jsonSchemaFile]->getSchemaDictionary()->getDefinition(
117+
if (isset($this->rawRefToCanonical[$jsonSchemaFile])) {
118+
$canonicalKey = $this->rawRefToCanonical[$jsonSchemaFile];
119+
return $this->parsedExternalFileSchemas[$canonicalKey]->getSchemaDictionary()->getDefinition(
116120
"#$externalKey",
117121
$schemaProcessor,
118122
$path,
@@ -136,23 +140,73 @@ private function parseExternalFile(
136140
SchemaProcessor $schemaProcessor,
137141
array &$path,
138142
): ?SchemaDefinition {
143+
// Resolve the ref to obtain the canonical file path or URL via the provider.
144+
// Using $jsonSchema->getFile() as the key (not the raw $jsonSchemaFile argument) ensures
145+
// that relative refs from different directories pointing to the same file share one entry,
146+
// and that network URLs resolved via $id are handled correctly.
139147
$jsonSchema = $schemaProcessor->getSchemaProvider()->getRef(
140148
$this->schema->getFile(),
141149
$this->schema->getJson()['$id'] ?? null,
142150
$jsonSchemaFile,
143151
);
144152

145-
// set up a dummy schema to fetch the definitions from the external file
146-
$schema = new Schema(
147-
'',
148-
$schemaProcessor->getCurrentClassPath(),
149-
'ExternalSchema',
150-
$jsonSchema,
151-
new self($jsonSchema),
152-
);
153+
$canonicalKey = $jsonSchema->getFile();
154+
155+
if ($existingSchema = $schemaProcessor->getProcessedFileSchema($canonicalKey)) {
156+
// The file was already processed (either as a top-level initial class, or by an
157+
// earlier $ref from another schema). Reuse the existing schema — no duplicate class.
158+
$this->parsedExternalFileSchemas[$canonicalKey] = $existingSchema;
159+
$this->rawRefToCanonical[$jsonSchemaFile] = $canonicalKey;
160+
161+
return $existingSchema->getSchemaDictionary()->getDefinition($externalKey, $schemaProcessor, $path);
162+
}
163+
164+
// First encounter of this external file. If the file lives within the provider's base
165+
// directory it will eventually be iterated by the provider and must receive the canonical
166+
// class name derived from its filename. Process it eagerly now so the correct class is
167+
// used even when the referencing schema is discovered first (issue #116).
168+
//
169+
// If the file is outside the provider's base directory the provider will never iterate it,
170+
// so we use an ExternalSchema placeholder instead — this avoids rendering and requiring
171+
// the class on every generateModels() call when test infrastructure re-runs the generator
172+
// with a fresh SchemaProcessor that has no memory of previous runs.
173+
$baseDir = str_replace('\\', '/', $schemaProcessor->getSchemaProvider()->getBaseDirectory());
174+
$canonicalNorm = str_replace('\\', '/', $canonicalKey);
175+
$isInsideBaseDir = str_starts_with($canonicalNorm, $baseDir . '/') || $canonicalNorm === $baseDir;
176+
177+
if ($isInsideBaseDir) {
178+
// The file lives within the provider's base directory and will eventually be iterated.
179+
// Process it eagerly now so the canonical class name (derived from the filename) is
180+
// used regardless of which schema the provider discovers first (issue #116).
181+
// processTopLevelSchema also registers the schema in processedFileSchemas, so no
182+
// separate registerProcessedFileSchema call is needed here.
183+
//
184+
// processTopLevelSchema returns null when the file has no top-level type:object or
185+
// composition (e.g. a definitions-only file). Fall through to ExternalSchema in that
186+
// case so fragment refs inside it still resolve.
187+
$schema = $schemaProcessor->processTopLevelSchema($jsonSchema);
188+
} else {
189+
$schema = null;
190+
}
191+
192+
if ($schema === null) {
193+
// The file is outside the provider's base directory, or it defines no top-level
194+
// object/composition. Use an ExternalSchema placeholder so fragment refs still
195+
// resolve, without rendering or requiring a class file on disk.
196+
$schema = new Schema(
197+
'',
198+
$schemaProcessor->getCurrentClassPath(),
199+
'ExternalSchema',
200+
$jsonSchema,
201+
new self($jsonSchema),
202+
);
203+
204+
$schema->getSchemaDictionary()->setUpDefinitionDictionary($schemaProcessor, $schema);
205+
$schemaProcessor->registerProcessedFileSchema($canonicalKey, $schema);
206+
}
153207

154-
$schema->getSchemaDictionary()->setUpDefinitionDictionary($schemaProcessor, $schema);
155-
$this->parsedExternalFileSchemas[$jsonSchemaFile] = $schema;
208+
$this->parsedExternalFileSchemas[$canonicalKey] = $schema;
209+
$this->rawRefToCanonical[$jsonSchemaFile] = $canonicalKey;
156210

157211
return $schema->getSchemaDictionary()->getDefinition($externalKey, $schemaProcessor, $path);
158212
}

src/PropertyProcessor/Property/ReferenceProcessor.php

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,33 @@ public function process(string $propertyName, JsonSchema $propertySchema): Prope
3232
$definition = $dictionary->getDefinition($reference, $this->schemaProcessor, $path);
3333

3434
if ($definition) {
35+
$definitionSchema = $definition->getSchema();
36+
3537
if (
36-
$this->schema->getClassPath() !== $definition->getSchema()->getClassPath() ||
37-
$this->schema->getClassName() !== $definition->getSchema()->getClassName() ||
38+
$this->schema->getClassPath() !== $definitionSchema->getClassPath() ||
39+
$this->schema->getClassName() !== $definitionSchema->getClassName() ||
3840
(
3941
$this->schema->getClassName() === 'ExternalSchema' &&
40-
$definition->getSchema()->getClassName() === 'ExternalSchema'
42+
$definitionSchema->getClassName() === 'ExternalSchema'
4143
)
4244
) {
4345
$this->schema->addNamespaceTransferDecorator(
44-
new SchemaNamespaceTransferDecorator($definition->getSchema()),
46+
new SchemaNamespaceTransferDecorator($definitionSchema),
4547
);
48+
49+
// When the definition resolves to a canonical (non-ExternalSchema) class that
50+
// lives in a different namespace from the current schema, register its FQCN
51+
// directly as a used class. The ExternalSchema intermediary that previously
52+
// performed this registration (transitively via its own usedClasses list) is
53+
// no longer created when the file was already processed; this explicit call
54+
// ensures the referencing schema's import list remains complete.
55+
if ($definitionSchema->getClassName() !== 'ExternalSchema') {
56+
$this->schema->addUsedClass(join('\\', array_filter([
57+
$this->schemaProcessor->getGeneratorConfiguration()->getNamespacePrefix(),
58+
$definitionSchema->getClassPath(),
59+
$definitionSchema->getClassName(),
60+
])));
61+
}
4662
}
4763

4864
return $definition->resolveReference($propertyName, $path, $this->propertyMetaDataCollection);

src/SchemaProcessor/SchemaProcessor.php

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,24 @@ class SchemaProcessor
3636
protected array $processedSchema = [];
3737
/** @var PropertyInterface[] Collect processed schemas to avoid duplicated classes */
3838
protected array $processedMergedProperties = [];
39+
/**
40+
* Global index of schemas keyed by the canonical file path or URL returned by
41+
* SchemaProviderInterface::getRef(). Used to deduplicate external $ref resolutions across
42+
* all schema processings, making class generation order-independent.
43+
*
44+
* When a $ref triggers processTopLevelSchema() for a file that the provider has not yet
45+
* reached, the canonical Schema is registered here before property processing begins. If
46+
* the provider later iterates the same file, generateModel() detects the match via the
47+
* combined file-path + content-signature check and returns the already-registered Schema
48+
* without creating a duplicate render job.
49+
*
50+
* Note: for providers such as OpenAPIv3Provider that yield multiple distinct schemas from
51+
* a single source file, each schema has a unique content signature; the signature check
52+
* prevents false-positive deduplication across schemas that merely share the same file.
53+
*
54+
* @var Schema[]
55+
*/
56+
protected array $processedFileSchemas = [];
3957
/** @var string[] */
4058
protected array $generatedFiles = [];
4159

@@ -120,6 +138,21 @@ protected function generateModel(
120138
return $this->processedSchema[$schemaSignature];
121139
}
122140

141+
// For initial-class calls: if this exact file+content was already processed eagerly via
142+
// processTopLevelSchema() (triggered by a $ref resolution), reuse that schema to avoid a
143+
// duplicate render job. Both checks are required:
144+
// - The file-path check detects that this file was already processed via a $ref.
145+
// - The signature check ensures we do not short-circuit when a different schema shares
146+
// the same source file (e.g. OpenAPI v3 where all component schemas are yielded from
147+
// the same spec file — each has a unique signature).
148+
if (
149+
$initialClass
150+
&& isset($this->processedSchema[$schemaSignature])
151+
&& $this->getProcessedFileSchema($jsonSchema->getFile()) !== null
152+
) {
153+
return $this->processedSchema[$schemaSignature];
154+
}
155+
123156
$schema = new Schema(
124157
$this->getTargetFileName($classPath, $className),
125158
$classPath,
@@ -130,7 +163,13 @@ protected function generateModel(
130163
$this->generatorConfiguration,
131164
);
132165

166+
// Register by content signature (secondary dedup for content-identical inline schemas).
133167
$this->processedSchema[$schemaSignature] = $schema;
168+
// Register by canonical file path/URL (primary dedup for external $ref resolutions).
169+
// Registering here — before property processing — ensures that any $ref back to this
170+
// file encountered while processing the referencing schema finds this canonical schema
171+
// immediately, regardless of which schema was discovered first by the provider.
172+
$this->registerProcessedFileSchema($jsonSchema->getFile(), $schema);
134173
$json = $jsonSchema->getJson();
135174
$json['type'] = 'base';
136175

@@ -310,10 +349,20 @@ function () use ($property, $schema, $mergedPropertySchema): void {
310349
*/
311350
protected function setCurrentClassPath(string $jsonSchemaFile): void
312351
{
313-
$path = str_replace($this->schemaProvider->getBaseDirectory(), '', dirname($jsonSchemaFile));
352+
$fileDir = str_replace('\\', '/', dirname($jsonSchemaFile));
353+
$baseDir = str_replace('\\', '/', $this->schemaProvider->getBaseDirectory());
354+
$relative = str_replace($baseDir, '', $fileDir);
355+
356+
// If the file is outside the provider's base directory, str_replace leaves the absolute
357+
// path untouched. In that case fall back to using just the last directory component so
358+
// the generated class path stays sensible rather than encoding an absolute path.
359+
if ($relative === $fileDir) {
360+
$relative = basename($fileDir);
361+
}
362+
314363
$pieces = array_map(
315364
static fn(string $directory): string => ucfirst((string) preg_replace('/\W/', '', $directory)),
316-
explode(DIRECTORY_SEPARATOR, $path),
365+
explode('/', $relative),
317366
);
318367

319368
$this->currentClassPath = join('\\', array_filter($pieces));
@@ -344,6 +393,62 @@ public function getSchemaProvider(): SchemaProviderInterface
344393
return $this->schemaProvider;
345394
}
346395

396+
public function getProcessedFileSchema(string $fileKey): ?Schema
397+
{
398+
return $this->processedFileSchemas[$this->normaliseFileKey($fileKey)] ?? null;
399+
}
400+
401+
public function registerProcessedFileSchema(string $fileKey, Schema $schema): void
402+
{
403+
$this->processedFileSchemas[$this->normaliseFileKey($fileKey)] = $schema;
404+
}
405+
406+
/**
407+
* Normalise a file path or URL to a consistent key for processedFileSchemas.
408+
* On Windows, RecursiveDirectoryIterator may produce backslash-separated paths while
409+
* RefResolverTrait produces forward-slash paths for the same file. Normalising to forward
410+
* slashes ensures the two representations map to the same key.
411+
*/
412+
private function normaliseFileKey(string $fileKey): string
413+
{
414+
return str_replace('\\', '/', $fileKey);
415+
}
416+
417+
/**
418+
* Process an external schema file with its canonical class name and path, exactly as
419+
* process() would, but without overwriting the current class path / class name context
420+
* (which belongs to the schema that triggered the $ref resolution).
421+
*
422+
* Returns the resulting Schema, or null if the file does not define an object/composition.
423+
*
424+
* @throws SchemaException
425+
*/
426+
public function processTopLevelSchema(JsonSchema $jsonSchema): ?Schema
427+
{
428+
$savedClassPath = $this->currentClassPath;
429+
$savedClassName = $this->currentClassName;
430+
431+
$this->setCurrentClassPath($jsonSchema->getFile());
432+
$this->currentClassName = $this->generatorConfiguration->getClassNameGenerator()->getClassName(
433+
str_ireplace('.json', '', basename($jsonSchema->getFile())),
434+
$jsonSchema,
435+
false,
436+
);
437+
438+
$schema = $this->processSchema(
439+
$jsonSchema,
440+
$this->currentClassPath,
441+
$this->currentClassName,
442+
new SchemaDefinitionDictionary($jsonSchema),
443+
true,
444+
);
445+
446+
$this->currentClassPath = $savedClassPath;
447+
$this->currentClassName = $savedClassName;
448+
449+
return $schema;
450+
}
451+
347452
private function getTargetFileName(string $classPath, string $className): string
348453
{
349454
return join(

src/SchemaProvider/OpenAPIv3Provider.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ class OpenAPIv3Provider implements SchemaProviderInterface
2626
*/
2727
public function __construct(private string $sourceFile)
2828
{
29+
$this->sourceFile = realpath($this->sourceFile) ?: $this->sourceFile;
2930
$jsonSchema = file_get_contents($this->sourceFile);
3031

3132
if (!$jsonSchema || !($this->openAPIv3Spec = json_decode($jsonSchema, true))) {

src/SchemaProvider/RecursiveDirectoryProvider.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ public function __construct(string $sourceDirectory)
3333
throw new FileSystemException("Source directory '$sourceDirectory' doesn't exist");
3434
}
3535

36-
$this->sourceDirectory = rtrim($sourceDirectory, "\\/");
36+
$this->sourceDirectory = realpath(rtrim($sourceDirectory, "\\/")) ?: rtrim($sourceDirectory, "\\/");
3737
}
3838

3939
/**

src/SchemaProvider/RefResolverTrait.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ private function getLocalRefPath(string $currentFile, string $ref): ?string
7373
if (!str_starts_with($jsonSchemaFile, '/')) {
7474
$candidate = $currentDir . '/' . $jsonSchemaFile;
7575

76-
return file_exists($candidate) ? $candidate : null;
76+
return file_exists($candidate) ? realpath($candidate) : null;
7777
}
7878

7979
// absolute paths: traverse up to find the context root directory
@@ -83,7 +83,7 @@ private function getLocalRefPath(string $currentFile, string $ref): ?string
8383
while (true) {
8484
$candidate = $dir . '/' . $relative;
8585
if (file_exists($candidate)) {
86-
return $candidate;
86+
return realpath($candidate);
8787
}
8888

8989
$parent = dirname($dir);

tests/ComposedValue/ComposedRequiredPromotionTest.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,24 @@ public function testAnyOfRequiredOnlyBranchesDoesNotCrash(): void
255255
$this->assertEmpty($rc->getProperties(ReflectionMethod::IS_PUBLIC));
256256
}
257257

258+
/**
259+
* anyOf: property is defined in root schema properties and required in every branch
260+
* (branches carry only 'required', no 'properties'). The root-level property must be
261+
* promoted to non-nullable because every anyOf branch guarantees its presence.
262+
*/
263+
#[DataProvider('implicitNullDataProvider')]
264+
public function testAnyOfRootPropertyRequiredInAllBranchesIsPromoted(bool $implicitNull): void
265+
{
266+
$className = $this->generateClassFromFile(
267+
'AnyOfRootPropertyRequiredInAllBranches.json',
268+
null,
269+
false,
270+
$implicitNull,
271+
);
272+
273+
$this->assertNonNullableStringProperty($className, 'name');
274+
}
275+
258276
/**
259277
* anyOf: branches define an untyped property (no 'type' keyword) in required —
260278
* promoteProperty returns early when getType() is null. No crash, and the
@@ -439,6 +457,23 @@ public function testIfThenElseOneBranchRequiredIsNotPromoted(bool $implicitNull)
439457
$this->assertNullableStringProperty($className, 'name');
440458
}
441459

460+
/**
461+
* if/else only (no then): required in else only → not promoted
462+
* (collectFromConditional count < 2 path — only one condition branch exists).
463+
*/
464+
#[DataProvider('implicitNullDataProvider')]
465+
public function testIfElseOnlyNotPromoted(bool $implicitNull): void
466+
{
467+
$className = $this->generateClassFromFile(
468+
'IfElseOnlyRequired.json',
469+
null,
470+
false,
471+
$implicitNull,
472+
);
473+
474+
$this->assertNullableStringProperty($className, 'name');
475+
}
476+
442477
/**
443478
* if/then/else: property already required at root level → short-circuit.
444479
*/

0 commit comments

Comments
 (0)