0
votes

I am using PHP Yii framework's Active Records to model a relation between two tables. The join involves a column and a literal, and could match 2+ rows but must be limited to only ever return 1 row.

I'm using Yii version 1.1.13, and MySQL 5.1.something.

My problem isn't the SQL, but how to configure the Yii model classes to work in all cases. I can get the classes to work sometimes (simple eager loading) but not always (never for lazy loading).

First I will describe the database. Then the goal. Then I will include examples of code I've tried and why it failed.

Sorry for the length, this is complex and examples are necessary.

The database:

TABLE sites
columns:
    id INT
    name VARCHAR
    type VARCHAR
rows:
    id  name     type
    --  -------  -----
    1   Site A   foo
    2   Site B   bar
    3   Site C   bar

TABLE field_options
columns:
    id INT
    field VARCHAR
    option_value VARCHAR
    option_label VARCHAR
rows:
    id  field        option_value   option_label
    --  -----------  -------------  -------------
    1   sites.type   foo            Foo Style Site
    2   sites.type   bar            Bar-Like Site
    3   sites.type   bar            Bar Site

So sites has an informal a reference to field_options where:

  1. field_options.field = 'sites.type' and
  2. field_options.option_value = sites.type

The goal:

The goal is for sites to look up the relevant field_options.option_label to go with its type value. If there happens to be more than one matching row, pick only one (any one, doesn't matter which).

Using SQL this is easy, I can do it 2 ways:

  1. I can join using a subquery:
    SELECT
        sites.id,
        f1.option_label AS type_label
    FROM sites
    LEFT JOIN field_options AS f1 ON f1.id = (
        SELECT id FROM field_options
        WHERE
            field_options.field = 'sites.type'
            AND field_options.option_value = sites.type
        LIMIT 1
    )
  1. Or I can use a subquery as a column reference in the select clause:
    SELECT
        sites.id,
        (
            SELECT id FROM field_options
            WHERE
                field_options.field = 'sites.type'
                AND field_options.option_value = sites.type
            LIMIT 1
        ) AS type_label
    FROM sites

Either way works great. So how do I model this in Yii??

What I've tried so far:

1. Use "on" array key in relation

I can get a simple eager lookup to work with this code:

class Sites extends CActiveRecord
{
    ...
    public function relations()
    {
        return array(
            'type_option' => array(
                self::BELONGS_TO,
                'FieldOptions', // that's the class for field_options
                '', // no normal foreign key
                'on' => "type_option.id = (SELECT id FROM field_options WHERE field = 'sites.type' AND option_value = t.type LIMIT 1)",
            ),
        );
    }
}

This works when I load a set of Sites objects and force it to eager load type_label, e.g. Sites::model()->with('type_label')->findByPk(1).

It does not work if type_label is lazy-loaded.

$site = Sites::model()->findByPk(1);
$label = $site->type_option->option_label; // ERROR: column t.type doesn't exist

2. Force eager loading always

Building on #1 above, I tried forcing Yii to always to eager loading, never lazy loading:

class Sites extends CActiveRecord
{
    public function relations()
    {
        ....
    }
    public function defaultScope()
    {
        return array(
            'with' => array( 'type_option' ),
        );
    }
}

Now everything always works when I load Sites, but it's no good because there are other models (not pictured here) that have relations that point to Sites, and those result in errors:

$site = Sites::model()->findByPk(1);
$label = $site->type_option->option_label; // works now

$other = OtherModel::model()->with('site_relation')->findByPk(1); // ERROR: column t.type doesn't exist, because 't' refers to OtherModel now

3. Make the reference to the base table somehow relative

If there was a way that I could refer to the base table, other than "t", that was guaranteed to point to the correct alias, that would work, e.g.

'on' => "type_option.id = (SELECT id FROM field_options WHERE field = 'sites.type' AND option_value = %%BASE_TABLE%%.type LIMIT 1)",

where %%BASE_TABLE%% always refers to the correct alias for table sites. But I know of no such token.

4. Add a true virtual database column

This way would be the best, if I could convince Yii that the table has an extra column, which should be loaded just like every other column, except the SQL is a subquery -- that would be awesome. But again, I don't see any way to mess with the column list, it's all done automatically.

So, after all that... does anyone have any ideas?

EDIT Mar 21/15: I just spent a long time investigating the possibility of subclassing parts of Yii to get the job done. No luck.

I tried creating a new type of relation based on BELONGS_TO (class CBelongsToRelation), to see if I could somehow add in context sensitivity so it could react differently depending on whether it was being lazy-loaded or not. But Yii isn't built that way. There is no place where I can hook in code during query buiding from inside a relation object. And there is also no way I can tell even what the base class is, relation objects have no link back to the parent model.

All of the code that assembles these queries for active records and their relations is locked up in a separate set of classes (CActiveFinder, CJoinQuery, etc.) that cannot be extended or replaced without replacing the entire AR system pretty much. So that's out.

I then tried to see if I can create "fake" database column entries that would actually be a subquery. Answer: no. I figured out how I could add additional columns to Yii's automatically generated schema data. But,
a) there's no way to define a column in such a way that it can be a derived value, Yii assumes it's a column name in way too many places for that; and
b) there also doesn't appear to be any way to avoid having it try to insert/update to those columns on save.

So it really is looking like Yii (1.x) just does not have any way to make this happen.

Limited solution provided by @eggyal in comments: @eggyal has a suggestion that will meet my needs. He suggests creating a MySQL view table to add extra columns for each label, using a subquery to look up the value. To allow editing, the view would have to be tied to a separate Yii class, so the downside is everywhere in my code I need to be aware of whether I'm loading a record for reading only (must use the view's class) or read/write (must use the base table's class, does not have the extra columns). That said, it is a workable solution for my particular case, maybe even the only solution -- although not an answer to this question as written, so I'm not going to put it in as an answer.

1
Option 4 could be achieved with a view. - eggyal
@eggyal you're right, I hadn't thought of that. However it looks like that would make the model read-only, because it looks like the view would be non-updatable: view page says a view is not updatable if it contains a subquery in the select list, which this would have to I think to - Achronos
could you not use the view for reading (when necessary) and write directly to the table otherwise? - eggyal
I could. It would be painful because I would need a separate Yii model class for each table, which means loading from a different class any time writing could be required (and avoiding the columns that don't exist in the writing class). Still... given what I've spent the last 2 days trying, that might be the absolutely only option I've got. Thanks - Achronos

1 Answers

0
votes

OK, after a lot of attempts, I have found a solution. Thanks to @eggyal for making me think about database views.

As a quick recap, my goal was:

  • link one Yii model (CActiveRecord) to another using a relation()
  • the table join is complex and could match more than one row
  • the relation must never join more than one row (i.e. LIMIT 1)

I got it to work by:

  • creating a view from the field_options base table, using SQL GROUP BY to eliminate duplicate rows
  • creating a separate Yii model (CActiveRecord class) for the view
  • using the new model/view for the relation(), not the original table

Even then there were some wrinkles (maybe a Yii bug?) I had to work around.

Here are all the details:

The SQL view:

CREATE VIEW field_options_distinct AS
    SELECT
        field,
        option_value,
        option_label
    FROM
        field_options
    GROUP BY
        field,
        option_value
;

This view contains only the columns I care about, and only ever one row per field/option_value pair.

The Yii model class:

class FieldOptionsDistinct extends CActiveRecord
{
    public function tableName()
    {
        return 'field_options_distinct'; // the view
    }

    /*
        I found I needed the following to override Yii's default table data.
        The view doesn't have a primary key, and that confused Yii's AR finding system
        and resulted in a PHP "invalid foreach()" error.

        So the code below works around it by diving into the Yii table metadata object
        and manually setting the primary key column list.
    */
    private $bMetaDataSet = FALSE;
    public function getMetaData()
    {
        $oMetaData = parent::getMetaData();
        if (!$this->bMetaDataSet) {
            $oMetaData->tableSchema->primaryKey = array( 'field', 'option_value' );
            $this->bMetaDataSet = TRUE;
        }
        return $oMetaData;
    }
}

The Yii relation():

class Sites extends CActiveRecord
{
    // ...

    public function relations()
    {
        return (
            'type_option' => array(
                self::BELONGS_TO,
                'FieldOptionsDistinct',
                array(
                    'type' => 'option_value',
                ),
                'on' => "type_option.field = 'sites.type'",
            ),
        );
    }
}

And all that does the trick. Easy, right?!?