Skip to content

Commit 1f16eb6

Browse files
authored
Merge pull request #111 from php-openapi/inverse-relations
inverse-relations and bugs-fixing
2 parents cb45e67 + d7714e1 commit 1f16eb6

73 files changed

Lines changed: 1141 additions & 251 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ name: yii2-docker
22
services:
33
php:
44
image: yii2-openapi-php:${PHP_VERSION:-8.3}
5+
pull_policy: build
56
build:
67
dockerfile: tests/docker/Dockerfile
78
context: .

src/generator/default/dbmodel.php

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,52 @@
44
* @var string $namespace
55
* @var string $relationNamespace
66
**/
7+
use cebe\yii2openapi\lib\items\AttributeRelation;
78
use yii\helpers\Inflector;
89
use yii\helpers\VarDumper;
910

11+
$allCoveredClasses = array_merge(
12+
array_map(fn($r) => $r->getClassName(), (array)$model->relations),
13+
array_map(fn($r) => $r->relatedClassName, (array)$model->many2many),
14+
array_map(fn($r) => $r->getClassName(), (array)$model->nonDbRelations)
15+
);
16+
/**
17+
* Resolve inverse relation method names from the FK column name of the other model.
18+
* Relations already covered by $model->relations, many2many, or nonDbRelations are skipped.
19+
* Relations from models declared with "x-table: false" are skipped (no real table, no FK).
20+
*
21+
* Naming logic (model being generated = e.g. "Lead", $modelSnake = "lead"):
22+
* 1. Take the FK column name from the other model (e.g. Order.customer_lead_id)
23+
* 2. Strip trailing "_id" → "customer_lead"
24+
* 3. Strip trailing "_<modelSnake>" → "customer"
25+
* 4. If a prefix remains, prepend it to the pluralized class name:
26+
* "customer" + "Orders" → getCustomerOrders()
27+
* If no prefix remains (plain FK like "lead_id"):
28+
* "" + "Orders" → getOrders()
29+
*
30+
* Examples: Order has three FK columns pointing to Lead ($model->name = "Lead"):
31+
*
32+
* FK column (on Order) | generated on Order | generated on Lead (inverse)
33+
* --------------------------|-------------------------|----------------------------
34+
* lead_id | getLead() | getOrders()
35+
* customer_lead_id | getCustomerLead() | getCustomerOrders()
36+
* billing_lead_id | getBillingLead() | getBillingOrders()
37+
*/
38+
$modelSnake = Inflector::underscore($model->name);
39+
$inverseRelations = array_map(function (AttributeRelation $relation) use ($modelSnake): array {
40+
$inverseMethod = $relation->getMethod() === 'hasOne' ? 'hasMany' : 'hasOne';
41+
$classBase = $inverseMethod === 'hasMany' ? Inflector::pluralize($relation->getCamelName()) : $relation->getCamelName();
42+
$fkBase = preg_replace('/_id$/', '', $relation->getForeignName());
43+
$prefix = preg_replace('/_?' . preg_quote($modelSnake, '/') . '$/', '', $fkBase);
44+
return [
45+
'relation' => $relation,
46+
'inverseMethod' => $inverseMethod,
47+
'inverseName' => $prefix !== '' ? Inflector::camelize($prefix) . $classBase : $classBase,
48+
];
49+
}, array_filter(
50+
(array)$model->belongsToRelations,
51+
fn(AttributeRelation $r) => !in_array($r->getClassName(), $allCoveredClasses) && $r->getTableName() !== ''
52+
));
1053
?>
1154
<?= '<?php' ?>
1255

@@ -45,6 +88,15 @@
4588
<?php foreach ($model->many2many as $relation): ?>
4689
* @property array|\<?= trim($relationNamespace, '\\') ?>\<?= $relation->relatedClassName ?>[] $<?= Inflector::variablize($relation->name) ?>
4790

91+
<?php endforeach; ?>
92+
<?php foreach ($inverseRelations as $inverse): ?>
93+
<?php if ($inverse['inverseMethod'] === 'hasOne'):?>
94+
* @property \<?= trim($relationNamespace, '\\') ?>\<?= $inverse['relation']->getClassName() ?> $<?= Inflector::variablize($inverse['inverseName']) ?>
95+
96+
<?php else:?>
97+
* @property array|\<?= trim($relationNamespace, '\\') ?>\<?= $inverse['relation']->getClassName() ?>[] $<?= Inflector::variablize($inverse['inverseName']) ?>
98+
99+
<?php endif?>
48100
<?php endforeach; ?>
49101
*/
50102
abstract class <?= $model->getClassName() ?> extends \yii\db\ActiveRecord
@@ -146,14 +198,17 @@ public function get<?= $relation->getCamelName() ?>()
146198
<?php endif;?>
147199
}
148200
<?php endforeach; ?>
149-
<?php $i = 1; $usedRelationNames = [];
150-
foreach ($model->belongsToRelations as $relationName => $relation): ?><?php $number = in_array($relation->getCamelName(), $usedRelationNames) ? $i : '' ?>
201+
<?php foreach ($model->nonDbRelations as $nonDbRelation): ?>
151202

152-
# belongs to relation
153-
public function get<?= $relation->getCamelName() . ($number) ?>()
203+
abstract public function get<?= $nonDbRelation->getCamelName() ?>(): \yii\db\ActiveQuery;
204+
<?php endforeach; ?>
205+
<?php foreach ($inverseRelations as $inverse): ?>
206+
207+
// inverse relation
208+
public function get<?= $inverse['inverseName'] ?>()
154209
{
155-
return $this-><?= $relation->getMethod() ?>(\<?= trim($relationNamespace, '\\') ?>\<?= $relation->getClassName() ?>::class, <?php
156-
echo $relation->linkToString() ?>);
210+
return $this-><?= $inverse['inverseMethod'] ?>(\<?= trim($relationNamespace, '\\') ?>\<?= $inverse['relation']->getClassName() ?>::class, <?php
211+
echo $inverse['relation']->linkToString() ?>);
157212
}
158-
<?php $i++; $usedRelationNames[] = $relation->getCamelName(); endforeach; ?>
213+
<?php endforeach; ?>
159214
}

src/lib/FakerStubResolver.php

Lines changed: 107 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,13 @@ public function resolve(): ?string
104104
$result = $this->fakeForFloat($limits['min'], $limits['max']);
105105
} elseif ($this->attribute->phpType === 'array' ||
106106
substr($this->attribute->phpType, -2) === '[]') {
107-
$result = $this->fakeForArray($this->property->getProperty());
107+
$property = $this->property->getProperty();
108+
if ($property->type === 'object') {
109+
// A JSONB/JSON column declared as type:object in the spec has phpType=array but must
110+
// be faked as an object, not as an array.
111+
return $this->fakeForObject($property);
112+
}
113+
$result = $this->fakeForArray($property);
108114
if ($result !== '$faker->words()') { # example for array will only work with a list/`$faker->words()`
109115
return $result;
110116
}
@@ -122,6 +128,7 @@ public function resolve(): ?string
122128

123129
$example = $this->property->getAttr('example');
124130
$example = VarExporter::export($example);
131+
$example = preg_replace('/\n/', "\n ", $example);
125132
return str_replace('$faker->', '$faker->optional(0.92, ' . $example . ')->', $result);
126133
}
127134

@@ -284,7 +291,8 @@ private function fakeForArray(SpecObjectInterface $property, int $count = 4): st
284291
$items = $property->items;
285292

286293
if (!$items) {
287-
return $this->arbitraryArray();
294+
// Required fields cannot use [] — Yii2's isEmpty() treats empty arrays as blank.
295+
return $this->attribute->required ? $this->arbitraryArray() : '[]';
288296
}
289297

290298
if ($items instanceof Reference) {
@@ -299,46 +307,121 @@ private function fakeForArray(SpecObjectInterface $property, int $count = 4): st
299307
if ($type === null) {
300308
return $this->arbitraryArray();
301309
}
302-
$aFaker = $this->aElementFaker($this->property->getProperty(), $this->attribute->columnName);
303310
if (in_array($type, ['string', 'number', 'integer', 'boolean', 'array'])) {
311+
$aFaker = $this->aElementFaker($this->property->getProperty(), $this->attribute->columnName);
304312
return $this->wrapInArray($aFaker, $uniqueItems, $count);
305313
}
306314

307315
if ($type === 'object') {
308316
$result = $this->fakeForObject($items);
317+
if ($result === '(object) []') {
318+
return '[]';
319+
}
309320
return $this->wrapInArray($result, $uniqueItems, $count);
310321
}
311322

312323
return '[]';
313324
}
314325

315326
/**
327+
* Generates a PHP array literal string for an OpenAPI object property.
328+
* The output is embedded as PHP code in Faker fixture files, not as JSON.
329+
*
330+
* Flow: Faker assigns a PHP array to the model property → ActiveRecord passes it
331+
* to the DB driver → the driver JSON-encodes it before storing.
332+
* Result in DB:
333+
* PHP [] → json_encode([]) → [] (JSON array)
334+
* PHP ['key' => 'v'] → json_encode(['key' => 'v']) → {"key": "v"} (JSON object)
335+
*
336+
* A non-empty associative array correctly becomes a JSON object in the DB.
337+
* An empty PHP array always becomes [] in the DB, never {} — regardless of whether
338+
* the OpenAPI field is typed as "object" or "array". For test/faker data without
339+
* defined properties this is acceptable, as no schema is enforced.
316340
* @internal
317341
*/
318-
public function fakeForObject(SpecObjectInterface $items): string
342+
public function fakeForObject(SpecObjectInterface $items, int $depth = 1): string
319343
{
320344
if (!$items->properties) {
321-
return $this->arbitraryArray();
345+
return '(object) []';
322346
}
323347

324-
$props = '[' . PHP_EOL;
348+
$indent = str_repeat(' ', $depth + 3);
349+
$closingIndent = str_repeat(' ', $depth + 2);
350+
$parts = [];
325351

326352
foreach ($items->properties as $name => $prop) {
327353
/** @var SpecObjectInterface $prop */
328354

329-
if (!empty($prop->properties)) { // nested object
330-
$result = $this->{__FUNCTION__}($prop);
355+
if (!$prop instanceof Reference && ($prop->type === 'object' || !empty($prop->properties))) {
356+
$result = $this->fakeForObject($prop, $depth + 1);
331357
} else {
332358
$result = $this->aElementFaker(['items' => $prop->getSerializableData()], $name);
359+
if (str_starts_with($result, 'array_map')) {
360+
$result = $this->reindentArrayMapForObject($result, $depth);
361+
}
333362
}
334-
$props .= '\'' . $name . '\' => ' . $result . ',' . PHP_EOL;
363+
$parts[] = $indent . '\'' . $name . '\' => ' . $result . ',';
335364
}
336365

337-
$props .= ']';
366+
$props = '[' . PHP_EOL . implode(PHP_EOL, $parts) . PHP_EOL . $closingIndent . ']';
338367

339368
return $props;
340369
}
341370

371+
/**
372+
* Re-indents a compact wrapInArray() output string to match the correct depth inside fakeForObject().
373+
* wrapInArray() always uses hardcoded 12/8-space indentation; when its result is embedded as a
374+
* property value inside a fakeForObject() output at depth >= 1, the indentation must be adjusted.
375+
* For a nested array_map body the inner call is expanded to multi-line style via expandCompactArrayMap().
376+
*/
377+
private function reindentArrayMapForObject(string $code, int $depth): string
378+
{
379+
$bodyIndent = str_repeat(' ', $depth + 4);
380+
$closeIndent = str_repeat(' ', $depth + 3);
381+
382+
$pat = '/^array_map\(function \(\) use \(\$faker, \$uniqueFaker\) \{\n (.*)\n \}, range\(1, (\d+)\)\)$/s';
383+
if (!preg_match($pat, $code, $m)) {
384+
return $code;
385+
}
386+
[$body, $count] = [$m[1], $m[2]];
387+
388+
if (str_starts_with($body, 'return array_map(')) {
389+
$inner = substr($body, 7, -1); // strip "return " prefix and trailing ";"
390+
$expanded = $this->expandCompactArrayMap($inner, $bodyIndent);
391+
return "array_map(function () use (\$faker, \$uniqueFaker) {\n"
392+
. $bodyIndent . "return {$expanded};\n"
393+
. $closeIndent . "},\n"
394+
. $closeIndent . "range(1, {$count}))";
395+
}
396+
397+
return "array_map(function () use (\$faker, \$uniqueFaker) {\n"
398+
. $bodyIndent . $body . "\n"
399+
. $closeIndent . "},\n"
400+
. $closeIndent . "range(1, {$count}))";
401+
}
402+
403+
/**
404+
* Expands a compact wrapInArray() string (single-line function + range) into multi-line style,
405+
* using $baseIndent as the reference indentation level for the opening "array_map(" line.
406+
*/
407+
private function expandCompactArrayMap(string $code, string $baseIndent): string
408+
{
409+
$pat = '/^array_map\(function \(\) use \(\$faker, \$uniqueFaker\) \{\n (.*)\n \}, range\(1, (\d+)\)\)$/s';
410+
if (!preg_match($pat, $code, $m)) {
411+
return $code;
412+
}
413+
[$body, $count] = [$m[1], $m[2]];
414+
$funcIndent = $baseIndent . ' ';
415+
$innerIndent = $baseIndent . ' ';
416+
417+
return "array_map(\n"
418+
. $funcIndent . "function () use (\$faker, \$uniqueFaker) {\n"
419+
. $innerIndent . $body . "\n"
420+
. $funcIndent . "},\n"
421+
. $funcIndent . "range(1, {$count})\n"
422+
. $baseIndent . ")";
423+
}
424+
342425
/**
343426
* This method must be only used incase of array
344427
* @param SpecObjectInterface $items
@@ -355,15 +438,28 @@ public function fakeForObject(SpecObjectInterface $items): string
355438
public function handleOneOf(SpecObjectInterface $items, int $count): string
356439
{
357440
$result = '';
441+
$indent = str_repeat(' ', 12);
358442
foreach ($items->oneOf as $key => $aDataType) {
359443
/** @var Schema|Reference $aDataType */
360444

361445
$inp = $aDataType instanceof Reference ? $aDataType : ['items' => $aDataType->getSerializableData()];
362446
$aFaker = $this->aElementFaker($inp, $this->attribute->columnName);
447+
/**
448+
* Each $dataTypeN gets its own line (12-space indent = wrapInArray body level).
449+
* wrapInArray output (array_map) gets +4 spaces on continuation lines (12→16, 8→12).
450+
* fakeForObject output (starts with "[") is left as-is — depth=1 already gives 16/12.
451+
* return goes on its own line.
452+
*/
453+
if (str_contains($aFaker, PHP_EOL) && !str_starts_with($aFaker, '[')) {
454+
$aFaker = str_replace(PHP_EOL, PHP_EOL . ' ', $aFaker);
455+
}
456+
if ($result !== '') {
457+
$result .= PHP_EOL . $indent;
458+
}
363459
$result .= '$dataType' . $key . ' = ' . $aFaker . ';';
364460
}
365461
$ct = count($items->oneOf) - 1;
366-
$result .= 'return ${"dataType".rand(0, ' . $ct . ')}';
462+
$result .= PHP_EOL . $indent . 'return ${"dataType".rand(0, ' . $ct . ')}';
367463
return $result;
368464
}
369465

src/lib/openapi/PropertySchema.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ public function __construct(SpecObjectInterface $property, string $name, Compone
100100
$this->isPk = $name === $schema->getPkName();
101101

102102
$onUpdate = $onDelete = $xFaker = $reference = $fkColName = null;
103+
$xDbTypeFalse = false;
103104

104105
foreach ($property->allOf ?? [] as $element) {
105106
// x-fk-on-delete | x-fk-on-update
@@ -122,6 +123,11 @@ public function __construct(SpecObjectInterface $property, string $name, Compone
122123
if (!empty($element->{CustomSpecAttr::FK_COLUMN_NAME})) {
123124
$fkColName = $element->{CustomSpecAttr::FK_COLUMN_NAME};
124125
}
126+
127+
// x-db-type: false → treat as non-DB reference (no FK column, no migration)
128+
if (isset($element->{CustomSpecAttr::DB_TYPE}) && $element->{CustomSpecAttr::DB_TYPE} === false) {
129+
$xDbTypeFalse = true;
130+
}
125131
}
126132

127133
if (
@@ -143,6 +149,9 @@ public function __construct(SpecObjectInterface $property, string $name, Compone
143149
$this->xFaker = $xFaker;
144150
$this->property = $reference;
145151
$property = $this->property;
152+
} elseif ($xDbTypeFalse && $reference instanceof Reference) {
153+
$this->property = $reference;
154+
$property = $this->property;
146155
}
147156

148157
// don't go reference part if `x-no-relation` is true
@@ -152,6 +161,9 @@ public function __construct(SpecObjectInterface $property, string $name, Compone
152161

153162
if ($property instanceof Reference) {
154163
$this->initReference();
164+
if ($xDbTypeFalse) {
165+
$this->isNonDbReference = true;
166+
}
155167
} elseif (
156168
isset($property->type, $property->items) && $property->type === 'array'
157169
&& $property->items instanceof Reference

tests/docker/Dockerfile

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,8 @@ ENV DEBIAN_FRONTEND=noninteractive
99
RUN echo "force-unsafe-io" > /etc/dpkg/dpkg.cfg.d/02apt-speedup && \
1010
echo "Acquire::http {No-Cache=True;};" > /etc/apt/apt.conf.d/no-cache
1111
RUN apt-get update && \
12-
apt-get -y install \
13-
gnupg2 && \
14-
apt-key update && \
15-
apt-get update && \
1612
apt-get install -y --no-install-recommends \
13+
gnupg2 \
1714
imagemagick \
1815
libmagickwand-dev libmagickcore-dev \
1916
libfreetype6-dev \
@@ -35,7 +32,7 @@ RUN apt-get update && \
3532
apt-get clean && \
3633
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* \
3734
# https://xdebug.org/docs/compat \
38-
&& if [ "$BUILD_PHP_VERSION" = "8.2" ] || [ "$BUILD_PHP_VERSION" = "8.3" ] ; then pecl install xdebug ; else pecl install xdebug-3.1.5 ; fi \
35+
&& if [ "$BUILD_PHP_VERSION" = "8.2" ] || [ "$BUILD_PHP_VERSION" = "8.3" ] ; then pecl install xdebug-3.3.2 ; else pecl install xdebug-3.1.5 ; fi \
3936
&& docker-php-ext-enable xdebug \
4037
&& docker-php-ext-install \
4138
zip \

tests/fixtures/blog.php

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,11 +117,9 @@
117117
->setDescription('The User')
118118
->setFakerStub('$faker->randomElement(\app\models\User::find()->select("id")->column())'),
119119
'message' => (new Attribute('message', ['phpType' => 'array', 'dbType' => 'json', 'xDbType' => 'json']))
120-
->setRequired()->setDefault([])->setFakerStub('$faker->words()'),
120+
->setRequired()->setDefault([])->setXDescriptionIsComment(null)->setFakerStub('$faker->words()'),
121121
'meta_data' => (new Attribute('meta_data', ['phpType' => 'array', 'dbType' => 'json', 'xDbType' => 'json']))
122-
->setDefault([])->setFakerStub('array_map(function () use ($faker, $uniqueFaker) {
123-
return $faker->words();
124-
}, range(1, 4))'),
122+
->setDefault([])->setXDescriptionIsComment(null)->setFakerStub('[]'),
125123
'created_at' => (new Attribute('created_at',['phpType' => 'int', 'dbType' => 'integer']))
126124
->setRequired()->setFakerStub('$faker->unixTime'),
127125
],

tests/specs/blog/models/CommentFaker.php

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,7 @@ public function generateModel($attributes = [])
3333
$model->post_id = $faker->randomElement(\app\models\Post::find()->select("id")->column());
3434
$model->author_id = $faker->randomElement(\app\models\User::find()->select("id")->column());
3535
$model->message = $faker->words();
36-
$model->meta_data = array_map(function () use ($faker, $uniqueFaker) {
37-
return $faker->words();
38-
}, range(1, 4));
36+
$model->meta_data = [];
3937
$model->created_at = $faker->unixTime;
4038
if (!is_callable($attributes)) {
4139
$model->setAttributes($attributes, false);

tests/specs/blog/models/base/Category.php

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,4 @@ public function getPosts()
3838
{
3939
return $this->hasMany(\app\models\Post::class, ['category_id' => 'id'])->inverseOf('category');
4040
}
41-
42-
# belongs to relation
43-
public function getPost()
44-
{
45-
return $this->hasOne(\app\models\Post::class, ['category_id' => 'id']);
46-
}
4741
}

0 commit comments

Comments
 (0)