Skip to content

Virtual attributes allow a getter method, and separate "computed" #116

@siggi-k

Description

@siggi-k

Virtual (non-DB) attributes: allow a getter method, and separate "computed" from "query-populated"

Package: php-openapi/yii2-openapi (2.0-beta5)

Summary

When I declare a virtual attribute (a property that is not a database
column, x-db-type: false) in the OpenAPI spec, Gii generates code that makes
it impossible to provide the value with a getter method. For a value that is
computed in PHP (like a status derived from other fields) a getter is exactly
what I need, but the generated code blocks it.

The root cause is that the generator treats two different kinds of virtual
attribute as if they were the same thing:

  • (A) query-populated — a non-schema column that is filled by the query, e.g.
    SELECT *, (a + b) AS total. Here getAttribute('total') returns the selected
    value. This needs a real property + afterFind() copy. The current generated
    code is correct for this case.
  • (B) PHP-computed — a value produced by a getter method, e.g. status
    derived from acceptedAt / rejectedAt. This needs no real property and
    no afterFind() copy; it only needs a getter and a @property annotation.

Today only case (A) is generated, so case (B) is broken.

Example spec (a PHP-computed attribute)

Suggestion.yaml:

status:
  type: string
  enum:
    - accepted
    - rejected
    - pending
  description: enum [accepted, rejected, pending]
  readOnly: true
  x-db-type: false
  x-faker: false

What is generated today

In the generated base model (common/models/base/Suggestion.php):

protected $virtualAttributes = ['status'];

/**
 * @var string
 */
public $status;            // <-- real public property

public function attributes()
{
    return array_merge(parent::attributes(), $this->virtualAttributes);
}

public function afterFind()
{
    parent::afterFind();
    foreach ($this->virtualAttributes as $attr) {
        $this->$attr = $this->getAttribute($attr);   // copies the query value
    }
}

This is fine for case (A) (query-populated). It is wrong for case (B).

Problem 1 — for a PHP-computed attribute the afterFind() line is dead

$this->$attr = $this->getAttribute($attr);

For a PHP-computed attribute there is no such column and no selected value, so
getAttribute('status') returns null. The line just sets the property to
null after every find. (For case (A) the same line is useful — that is exactly
why the two cases must be told apart.)

Problem 2 — the generated public $status; blocks a getter

In Yii2, the magic getter is only reached if there is no real property named
status. Because the generator declares a real public $status;, the property
always wins and a getStatus() getter is never called.

I want to compute the value in the child model:

public function getStatus(): string
{
    return match (true) {
        $this->acceptedAt !== null => self::STATUS_ACCEPTED,
        $this->rejectedAt !== null => self::STATUS_REJECTED,
        default => self::STATUS_PENDING,
    };
}

But this getter never runs, so $model->status is always null today. (It only
appears to work because the transformer calls getStatus() by hand — see
Problem 3.)

Important detail: removing the public property is not enough

It is tempting to think "just drop public $status; and add a @property
annotation, then the magic getter works". It does not, because of how
yii\db\BaseActiveRecord::__get() works:

public function __get($name)
{
    if (array_key_exists($name, $this->_attributes)) {
        return $this->_attributes[$name];
    }
    if ($this->hasAttribute($name)) {     // <-- returns null BEFORE the getter
        return null;
    }
    ...
    return parent::__get($name);          // <-- only here getStatus() would run
}

and hasAttribute():

public function hasAttribute($name)
{
    return isset($this->_attributes[$name]) || in_array($name, $this->attributes(), true);
}

Since attributes() still merges virtualAttributes (which contains status),
hasAttribute('status') is true, so __get() returns null and never
reaches the getter
.

So to make a getter work, a PHP-computed attribute must be removed from all of:
public $...;, virtualAttributes, attributes(), and the afterFind() loop.

Problem 3 (follow-up) — transformer must repeat the getter by hand

Because the magic getter does not work, the value is also set manually in the
transformer, even though a getter already exists:

// api/transformers/SuggestionTransformer.php
$transform['status'] = $model->getStatus();

If the attribute worked through the normal getter, the generated transformer
could pick it up automatically and this manual line would not be needed.

Important: the writable non-DB use case must keep working

Not every non-DB attribute is read-only or computed. For example
SuggestionSetting.yaml:

rule:
  type: object
  readOnly: false
  x-db-type: false
  description: 'Single Rule'
  x-faker: false

Here rule is a writable input attribute. It is loaded from the request and
read back in the controller:

// api/controllers/SuggestionSettingController.php
$model->scenario = SuggestionSetting::SCENARIO_RULE_ADD;
$model->load($this->getResourceAttributes(), '');
if (empty($model->rule)) {
    throw new BadRequestHttpException(...);
}
$model->addDefinedRule((array) $model->rule);

For this case the real public $rule; property is needed, because the value
comes in from the client and is read again. This must keep working.

Note that readOnly alone is not a good discriminator: it only tells
read vs. write, not "PHP-computed (getter)" vs. "query-populated (data holder)".
A readOnly attribute could still be a query-populated column (case A). That is
why the proposal below uses an explicit marker.

Proposed solution

Three parts that fit together: an explicit marker (A), forced implementation (B),
and automatic transformer output (C).

Alt A — explicit marker for PHP-computed attributes

Introduce a vendor extension, e.g. x-virtual: getter (name open for
discussion; x-computed: true would also work), to mark case (B):

status:
  type: string
  enum: [accepted, rejected, pending]
  readOnly: true
  x-db-type: false
  x-virtual: getter        # <-- PHP-computed via getter
  x-faker: false

When x-virtual: getter is set, the generator must:

  • not generate a real public $status; property,

  • not add it to virtualAttributes,

  • not add it to attributes(),

  • not add it to the afterFind() loop,

  • emit only a @property PHPDoc line in the class doc block:

    /**
     * ...
     * @property string $status
     */

readOnly then keeps its normal meaning (request vs. response) and is no longer
overloaded. Attributes without x-virtual keep today's behavior, so case (A)
(query-populated) and the writable rule case are unchanged.

Alt B — generate an abstract getter to force implementation

For x-virtual: getter attributes, also emit an abstract getter in the
generated (abstract) base model:

abstract public function getStatus(): string;

Benefits:

  • the child model is forced to implement it (no silent null),
  • it works with the magic getter (the concrete instance has a real method),
  • it is cleaner than a concrete stub, which would be overwritten on regenerate.

(If the base model is not abstract in some project, fall back to a concrete stub
that throws NotSupportedException until overridden.)

Alt C — transformer picks up the getter automatically

The generated transformer should output x-virtual: getter attributes via their
getter automatically, instead of forcing a hand-written line. For example:

if ($model->canGetProperty('status')) {
    $transform['status'] = $model->getStatus();
}

This removes the manual $transform['status'] = $model->getStatus(); and keeps
spec and output in sync. It is independent of Alt A/B, but together they make the
whole feature seamless.

Goal

  • It must be possible to provide a getter method that supplies the value of a
    non-DB, PHP-computed attribute (e.g. status), and have it work through Yii's
    normal magic getter and in the API output.
  • The existing usage of a query-populated non-DB attribute (current
    virtualAttributes / afterFind behavior) must keep working.
  • The existing usage of a writable non-DB attribute (e.g. rule, set from
    the request and read back in the controller) must keep working unchanged.

Metadata

Metadata

Assignees

Labels

invalidThis doesn't seem right

Type

No fields configured for Bug.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions