Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ name: yii2-docker
services:
php:
image: yii2-openapi-php:${PHP_VERSION:-8.3}
pull_policy: build
build:
dockerfile: tests/docker/Dockerfile
context: .
Expand Down
69 changes: 62 additions & 7 deletions src/generator/default/dbmodel.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,52 @@
* @var string $namespace
* @var string $relationNamespace
**/
use cebe\yii2openapi\lib\items\AttributeRelation;
use yii\helpers\Inflector;
use yii\helpers\VarDumper;

$allCoveredClasses = array_merge(
array_map(fn($r) => $r->getClassName(), (array)$model->relations),
array_map(fn($r) => $r->relatedClassName, (array)$model->many2many),
array_map(fn($r) => $r->getClassName(), (array)$model->nonDbRelations)
);
/**
* Resolve inverse relation method names from the FK column name of the other model.
* Relations already covered by $model->relations, many2many, or nonDbRelations are skipped.
* Relations from models declared with "x-table: false" are skipped (no real table, no FK).
*
* Naming logic (model being generated = e.g. "Lead", $modelSnake = "lead"):
* 1. Take the FK column name from the other model (e.g. Order.customer_lead_id)
* 2. Strip trailing "_id" → "customer_lead"
* 3. Strip trailing "_<modelSnake>" → "customer"
* 4. If a prefix remains, prepend it to the pluralized class name:
* "customer" + "Orders" → getCustomerOrders()
* If no prefix remains (plain FK like "lead_id"):
* "" + "Orders" → getOrders()
*
* Examples: Order has three FK columns pointing to Lead ($model->name = "Lead"):
*
* FK column (on Order) | generated on Order | generated on Lead (inverse)
* --------------------------|-------------------------|----------------------------
* lead_id | getLead() | getOrders()
* customer_lead_id | getCustomerLead() | getCustomerOrders()
* billing_lead_id | getBillingLead() | getBillingOrders()
*/
$modelSnake = Inflector::underscore($model->name);
$inverseRelations = array_map(function (AttributeRelation $relation) use ($modelSnake): array {
$inverseMethod = $relation->getMethod() === 'hasOne' ? 'hasMany' : 'hasOne';
$classBase = $inverseMethod === 'hasMany' ? Inflector::pluralize($relation->getCamelName()) : $relation->getCamelName();
$fkBase = preg_replace('/_id$/', '', $relation->getForeignName());
$prefix = preg_replace('/_?' . preg_quote($modelSnake, '/') . '$/', '', $fkBase);
return [
'relation' => $relation,
'inverseMethod' => $inverseMethod,
'inverseName' => $prefix !== '' ? Inflector::camelize($prefix) . $classBase : $classBase,
];
}, array_filter(
(array)$model->belongsToRelations,
fn(AttributeRelation $r) => !in_array($r->getClassName(), $allCoveredClasses) && $r->getTableName() !== ''
));
?>
<?= '<?php' ?>

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

<?php endforeach; ?>
<?php foreach ($inverseRelations as $inverse): ?>
<?php if ($inverse['inverseMethod'] === 'hasOne'):?>
* @property \<?= trim($relationNamespace, '\\') ?>\<?= $inverse['relation']->getClassName() ?> $<?= Inflector::variablize($inverse['inverseName']) ?>

<?php else:?>
* @property array|\<?= trim($relationNamespace, '\\') ?>\<?= $inverse['relation']->getClassName() ?>[] $<?= Inflector::variablize($inverse['inverseName']) ?>

<?php endif?>
<?php endforeach; ?>
*/
abstract class <?= $model->getClassName() ?> extends \yii\db\ActiveRecord
Expand Down Expand Up @@ -146,14 +198,17 @@ public function get<?= $relation->getCamelName() ?>()
<?php endif;?>
}
<?php endforeach; ?>
<?php $i = 1; $usedRelationNames = [];
foreach ($model->belongsToRelations as $relationName => $relation): ?><?php $number = in_array($relation->getCamelName(), $usedRelationNames) ? $i : '' ?>
<?php foreach ($model->nonDbRelations as $nonDbRelation): ?>

# belongs to relation
public function get<?= $relation->getCamelName() . ($number) ?>()
abstract public function get<?= $nonDbRelation->getCamelName() ?>(): \yii\db\ActiveQuery;
<?php endforeach; ?>
<?php foreach ($inverseRelations as $inverse): ?>

// inverse relation
public function get<?= $inverse['inverseName'] ?>()
{
return $this-><?= $relation->getMethod() ?>(\<?= trim($relationNamespace, '\\') ?>\<?= $relation->getClassName() ?>::class, <?php
echo $relation->linkToString() ?>);
return $this-><?= $inverse['inverseMethod'] ?>(\<?= trim($relationNamespace, '\\') ?>\<?= $inverse['relation']->getClassName() ?>::class, <?php
echo $inverse['relation']->linkToString() ?>);
}
<?php $i++; $usedRelationNames[] = $relation->getCamelName(); endforeach; ?>
<?php endforeach; ?>
}
108 changes: 99 additions & 9 deletions src/lib/FakerStubResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ public function resolve(): ?string

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

Expand Down Expand Up @@ -284,7 +285,8 @@ private function fakeForArray(SpecObjectInterface $property, int $count = 4): st
$items = $property->items;

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

if ($items instanceof Reference) {
Expand All @@ -299,46 +301,121 @@ private function fakeForArray(SpecObjectInterface $property, int $count = 4): st
if ($type === null) {
return $this->arbitraryArray();
}
$aFaker = $this->aElementFaker($this->property->getProperty(), $this->attribute->columnName);
if (in_array($type, ['string', 'number', 'integer', 'boolean', 'array'])) {
$aFaker = $this->aElementFaker($this->property->getProperty(), $this->attribute->columnName);
return $this->wrapInArray($aFaker, $uniqueItems, $count);
}

if ($type === 'object') {
$result = $this->fakeForObject($items);
if ($result === '[]') {
return '[]';
}
return $this->wrapInArray($result, $uniqueItems, $count);
}

return '[]';
}

/**
* Generates a PHP array literal string for an OpenAPI object property.
* The output is embedded as PHP code in Faker fixture files, not as JSON.
*
* Flow: Faker assigns a PHP array to the model property → ActiveRecord passes it
* to the DB driver → the driver JSON-encodes it before storing.
* Result in DB:
* PHP [] → json_encode([]) → [] (JSON array)
* PHP ['key' => 'v'] → json_encode(['key' => 'v']) → {"key": "v"} (JSON object)
*
* A non-empty associative array correctly becomes a JSON object in the DB.
* An empty PHP array always becomes [] in the DB, never {} — regardless of whether
* the OpenAPI field is typed as "object" or "array". For test/faker data without
* defined properties this is acceptable, as no schema is enforced.
* @internal
*/
public function fakeForObject(SpecObjectInterface $items): string
public function fakeForObject(SpecObjectInterface $items, int $depth = 1): string
{
if (!$items->properties) {
return $this->arbitraryArray();
return '[]';
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

empty object should be {}

Suggested change
return '[]';
return '{}';

}

$props = '[' . PHP_EOL;
$indent = str_repeat(' ', $depth + 3);
$closingIndent = str_repeat(' ', $depth + 2);
$parts = [];

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

if (!empty($prop->properties)) { // nested object
$result = $this->{__FUNCTION__}($prop);
$result = $this->fakeForObject($prop, $depth + 1);
} else {
$result = $this->aElementFaker(['items' => $prop->getSerializableData()], $name);
if (str_starts_with($result, 'array_map')) {
$result = $this->reindentArrayMapForObject($result, $depth);
}
}
$props .= '\'' . $name . '\' => ' . $result . ',' . PHP_EOL;
$parts[] = $indent . '\'' . $name . '\' => ' . $result . ',';
}

$props .= ']';
$props = '[' . PHP_EOL . implode(PHP_EOL, $parts) . PHP_EOL . $closingIndent . ']';

return $props;
}

/**
* Re-indents a compact wrapInArray() output string to match the correct depth inside fakeForObject().
* wrapInArray() always uses hardcoded 12/8-space indentation; when its result is embedded as a
* property value inside a fakeForObject() output at depth >= 1, the indentation must be adjusted.
* For a nested array_map body the inner call is expanded to multi-line style via expandCompactArrayMap().
*/
private function reindentArrayMapForObject(string $code, int $depth): string
{
$bodyIndent = str_repeat(' ', $depth + 4);
$closeIndent = str_repeat(' ', $depth + 3);

$pat = '/^array_map\(function \(\) use \(\$faker, \$uniqueFaker\) \{\n (.*)\n \}, range\(1, (\d+)\)\)$/s';
if (!preg_match($pat, $code, $m)) {
return $code;
}
[$body, $count] = [$m[1], $m[2]];

if (str_starts_with($body, 'return array_map(')) {
$inner = substr($body, 7, -1); // strip "return " prefix and trailing ";"
$expanded = $this->expandCompactArrayMap($inner, $bodyIndent);
return "array_map(function () use (\$faker, \$uniqueFaker) {\n"
. $bodyIndent . "return {$expanded};\n"
. $closeIndent . "},\n"
. $closeIndent . "range(1, {$count}))";
}

return "array_map(function () use (\$faker, \$uniqueFaker) {\n"
. $bodyIndent . $body . "\n"
. $closeIndent . "},\n"
. $closeIndent . "range(1, {$count}))";
}

/**
* Expands a compact wrapInArray() string (single-line function + range) into multi-line style,
* using $baseIndent as the reference indentation level for the opening "array_map(" line.
*/
private function expandCompactArrayMap(string $code, string $baseIndent): string
{
$pat = '/^array_map\(function \(\) use \(\$faker, \$uniqueFaker\) \{\n (.*)\n \}, range\(1, (\d+)\)\)$/s';
if (!preg_match($pat, $code, $m)) {
return $code;
}
[$body, $count] = [$m[1], $m[2]];
$funcIndent = $baseIndent . ' ';
$innerIndent = $baseIndent . ' ';

return "array_map(\n"
. $funcIndent . "function () use (\$faker, \$uniqueFaker) {\n"
. $innerIndent . $body . "\n"
. $funcIndent . "},\n"
. $funcIndent . "range(1, {$count})\n"
. $baseIndent . ")";
}

/**
* This method must be only used incase of array
* @param SpecObjectInterface $items
Expand All @@ -355,15 +432,28 @@ public function fakeForObject(SpecObjectInterface $items): string
public function handleOneOf(SpecObjectInterface $items, int $count): string
{
$result = '';
$indent = str_repeat(' ', 12);
foreach ($items->oneOf as $key => $aDataType) {
/** @var Schema|Reference $aDataType */

$inp = $aDataType instanceof Reference ? $aDataType : ['items' => $aDataType->getSerializableData()];
$aFaker = $this->aElementFaker($inp, $this->attribute->columnName);
/**
* Each $dataTypeN gets its own line (12-space indent = wrapInArray body level).
* wrapInArray output (array_map) gets +4 spaces on continuation lines (12→16, 8→12).
* fakeForObject output (starts with "[") is left as-is — depth=1 already gives 16/12.
* return goes on its own line.
*/
if (str_contains($aFaker, PHP_EOL) && !str_starts_with($aFaker, '[')) {
$aFaker = str_replace(PHP_EOL, PHP_EOL . ' ', $aFaker);
}
if ($result !== '') {
$result .= PHP_EOL . $indent;
}
$result .= '$dataType' . $key . ' = ' . $aFaker . ';';
}
$ct = count($items->oneOf) - 1;
$result .= 'return ${"dataType".rand(0, ' . $ct . ')}';
$result .= PHP_EOL . $indent . 'return ${"dataType".rand(0, ' . $ct . ')}';
return $result;
}

Expand Down
12 changes: 12 additions & 0 deletions src/lib/openapi/PropertySchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ public function __construct(SpecObjectInterface $property, string $name, Compone
$this->isPk = $name === $schema->getPkName();

$onUpdate = $onDelete = $xFaker = $reference = $fkColName = null;
$xDbTypeFalse = false;

foreach ($property->allOf ?? [] as $element) {
// x-fk-on-delete | x-fk-on-update
Expand All @@ -122,6 +123,11 @@ public function __construct(SpecObjectInterface $property, string $name, Compone
if (!empty($element->{CustomSpecAttr::FK_COLUMN_NAME})) {
$fkColName = $element->{CustomSpecAttr::FK_COLUMN_NAME};
}

// x-db-type: false → treat as non-DB reference (no FK column, no migration)
if (isset($element->{CustomSpecAttr::DB_TYPE}) && $element->{CustomSpecAttr::DB_TYPE} === false) {
$xDbTypeFalse = true;
}
}

if (
Expand All @@ -143,6 +149,9 @@ public function __construct(SpecObjectInterface $property, string $name, Compone
$this->xFaker = $xFaker;
$this->property = $reference;
$property = $this->property;
} elseif ($xDbTypeFalse && $reference instanceof Reference) {
$this->property = $reference;
$property = $this->property;
}

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

if ($property instanceof Reference) {
$this->initReference();
if ($xDbTypeFalse) {
$this->isNonDbReference = true;
}
} elseif (
isset($property->type, $property->items) && $property->type === 'array'
&& $property->items instanceof Reference
Expand Down
5 changes: 1 addition & 4 deletions tests/docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,8 @@ ENV DEBIAN_FRONTEND=noninteractive
RUN echo "force-unsafe-io" > /etc/dpkg/dpkg.cfg.d/02apt-speedup && \
echo "Acquire::http {No-Cache=True;};" > /etc/apt/apt.conf.d/no-cache
RUN apt-get update && \
apt-get -y install \
gnupg2 && \
apt-key update && \
apt-get update && \
apt-get install -y --no-install-recommends \
gnupg2 \
imagemagick \
libmagickwand-dev libmagickcore-dev \
libfreetype6-dev \
Expand Down
6 changes: 2 additions & 4 deletions tests/fixtures/blog.php
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,9 @@
->setDescription('The User')
->setFakerStub('$faker->randomElement(\app\models\User::find()->select("id")->column())'),
'message' => (new Attribute('message', ['phpType' => 'array', 'dbType' => 'json', 'xDbType' => 'json']))
->setRequired()->setDefault([])->setFakerStub('$faker->words()'),
->setRequired()->setDefault([])->setXDescriptionIsComment(null)->setFakerStub('$faker->words()'),
'meta_data' => (new Attribute('meta_data', ['phpType' => 'array', 'dbType' => 'json', 'xDbType' => 'json']))
->setDefault([])->setFakerStub('array_map(function () use ($faker, $uniqueFaker) {
return $faker->words();
}, range(1, 4))'),
->setDefault([])->setXDescriptionIsComment(null)->setFakerStub('[]'),
'created_at' => (new Attribute('created_at',['phpType' => 'int', 'dbType' => 'integer']))
->setRequired()->setFakerStub('$faker->unixTime'),
],
Expand Down
4 changes: 1 addition & 3 deletions tests/specs/blog/models/CommentFaker.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,7 @@ public function generateModel($attributes = [])
$model->post_id = $faker->randomElement(\app\models\Post::find()->select("id")->column());
$model->author_id = $faker->randomElement(\app\models\User::find()->select("id")->column());
$model->message = $faker->words();
$model->meta_data = array_map(function () use ($faker, $uniqueFaker) {
return $faker->words();
}, range(1, 4));
$model->meta_data = [];
$model->created_at = $faker->unixTime;
if (!is_callable($attributes)) {
$model->setAttributes($attributes, false);
Expand Down
6 changes: 0 additions & 6 deletions tests/specs/blog/models/base/Category.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,4 @@ public function getPosts()
{
return $this->hasMany(\app\models\Post::class, ['category_id' => 'id'])->inverseOf('category');
}

# belongs to relation
public function getPost()
{
return $this->hasOne(\app\models\Post::class, ['category_id' => 'id']);
}
}
6 changes: 0 additions & 6 deletions tests/specs/blog/models/base/Post.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,4 @@ public function getComments()
{
return $this->hasMany(\app\models\Comment::class, ['post_id' => 'uid'])->inverseOf('post');
}

# belongs to relation
public function getComment()
{
return $this->hasOne(\app\models\Comment::class, ['post_id' => 'uid']);
}
}
Loading
Loading