Objection.js: Default values for extra fields of through-relations

Created on 6 Dec 2017  路  13Comments  路  Source: Vincit/objection.js

First of all: thanks for this great library. It's really a pleasure to work with it! 鉂わ笍

Now to my question: We use join-tables to define access-levels between users and resources. This join table contains a scope field, which defines a user's access level (e.g. owner, editor etc.). On the resources itself, we define each group of users as separate relations (owners, editors etc.). This might seem cumbersome, but it simplifies data handling with normalized stores on the frontend a lot.

Here's a quick example, how these relations look like:

class Project extends Model {
  static get tableName() {
    return 'projects';
  }

  static get relationMappings() {
    return {
      owners: {
        relation: Model.ManyToManyRelation,
        modelClass: `${__dirname}/user`,
        join: {
          from: 'projects.id',
          through: {
            from: 'projects_users.project_id',
            to: 'projects_users.user_id',
            extra: ['scope'],
          },
          to: 'users.id',
        },
        filter: qb => qb.where('projects_users.scope', 'owner'),
      },
      editors: {
        relation: Model.ManyToManyRelation,
        modelClass: `${__dirname}/user`,
        join: {
          from: 'projects.id',
          through: {
            from: 'projects_users.project_id',
            to: 'projects_users.user_id',
            extra: ['scope'],
          },
          to: 'users.id',
        },
        filter: qb => qb.where('projects_users.scope', 'editor'),
      },
    };
  }
}

Thanks to filter, it's really easy to define those relations and fetch the specific user-groups from the database. But when it comes to adding new users to a group like

await project
    .$relatedQuery('owners')
    .relate(userId)

it doesn't set the scope field to owners:

insert into "projects_users" ("project_id", "user_id") values (?, ?)

What I need, is a way to define default values for the field scope within the relation mapping. Do you know a way to do that?

I could imagine to extend the extra field API to something like

{
  editors: {
    relation: Model.ManyToManyRelation,
    modelClass: `${__dirname}/user`,
    join: {
      from: 'projects.id',
      through: {
        from: 'projects_users.project_id',
        to: 'projects_users.user_id',
        extra: [{
            column: 'scope',
            alias: 'customPropertyName',
            onInsert: value => value || 'owner',
          },
        }],
      },
      to: 'users.id',
    },
    filter: qb => qb.where('projects_users.scope', 'editor'),
  },
}

Any thoughts on that? Do you think that's feasible? I would like to try a PR, if it's considered a valuable addition and there's no other way to solve it.

enhancement

Most helpful comment

Haha 馃槃 I've been meaning to add this feature for a long time but never got around to implementing it. This feature actually closes the issue number 19.

All 13 comments

Hi,

awesome to hear objection is working for you 馃憤

There is no easy way to get this done currently. You could probably hack something together using mergeContext, and $beforeInsert but that's unreadable for anyone who isn't the original author of that hack 馃槃

I'd like to make this possible. Your suggestion could work, but I'd like to avoid yet another syntax for extra fields. Would onInsert functions for through and root objects work for you? Something like this:

{
  editors: {
    relation: Model.ManyToManyRelation,
    modelClass: `${__dirname}/user`,

    join: {
      from: 'projects.id',

      through: {
        from: 'projects_users.project_id',
        to: 'projects_users.user_id',
        extra: { customPropertyName: 'scope' },
        onInsert: (model) => (model.value = 'owner')
      },

      to: 'users.id',
    },

    filter: qb => qb.where('projects_users.scope', 'editor'),

    // You wouldn't need this for this feature, but this would solve
    // other stuff like polymorphic associations
    onInsert: (model) => (model.foo = 'bar')
  },
}

I don't actually know if this would be feasible because there are so many corner cases to consider (graph inserts, graph upserts etc.) but I'd like to give this a shot.

I really appreciate the offer to work on a PR, but this would be an insane first contribution since so many corners of the code should be considered. I can start working on this if this turns out to be doable.

Wow, thanks for the quick reply! I totally agree with leaving extra as it is and I like the idea of adding it as a separate option to the through object. This would totally work for me.

Just let me know if I should review things. Or maybe I can at least help with documentation (BTW: another great part of this project!).

It turned out to be feasible and actually quite simple to implement. There's now a an experimental branch where this should already work. No tests or anything yet, but you can already try it out.

The hook is called beforeInsert to be consistent with model hook names. I realized that we don't actually need a hook for through object since extra properties are copied from the "parent" model to the join table row model. You can try something like this with the new branch

{
  editors: {
    relation: Model.ManyToManyRelation,
    modelClass: `${__dirname}/user`,

    join: {
      from: 'projects.id',

      through: {
        from: 'projects_users.project_id',
        to: 'projects_users.user_id',
        extra: { customPropertyName: 'scope' }
      },

      to: 'users.id',
    },

    filter: qb => qb.where('projects_users.scope', 'editor'),

    beforeInsert: (model) => {
      model.customPropertyName = 'editor';
    }
  },
}

Crap, we __do__ need the through.beforeInsert also to make relate operations work. Well, in your case it simply means moving the beforeInsert hook inside the through object. No need to define it twice. I'll add a support for that next.

I like the consistency of beforeInsert! 馃憤
Let me know, when I should test it. And thanks for your help and responsiveness, I highly appreciate that!

I wrote some tests and it seems to work now. You can test it using this kind of mapping:

{
  editors: {
    relation: Model.ManyToManyRelation,
    modelClass: `${__dirname}/user`,

    join: {
      from: 'projects.id',

      through: {
        from: 'projects_users.project_id',
        to: 'projects_users.user_id',
        extra: { customPropertyName: 'scope' }

        beforeInsert(model) {
          model.customPropertyName = 'editor';
        }
      },

      to: 'users.id',
    },

    filter(qb) {
      qb.where('projects_users.scope', 'editor')
    }
  },
}

Awesome! It works! But only without a custom property name (which is totally fine in my case):

through: {
  from: 'projects_users.project_id',
  to: 'projects_users.user_id',
  extra: ['scope'],
  beforeInsert(model) {
    model.scope = 'editor';
  },
},

As soon as I use extra: { customPropertyName: 'scope' }, it creates this SQL-command:

insert into "projects_users" ("customPropertyName", "project_id", "user_id") values (?, ?, ?)

EDIT: It even works without extra, just by using beforeInsert and setting the field. 馃憤

Oh, I thought it would work with the alias, but now that I think about it, this is how it probably should work. The alias (customPropertyName in this case) is used when the property is set to the parent object. I think it makes sense that the alias is not used in the "join row model instance".

Yes, makes sense to me. extra is probably rather a serialization-setting and we only related it to this new feature because of my first draft.

So from my side it's fine! I just saw that you already updated the docs. Too fast for me 馃槈

Haha 馃槃 I've been meaning to add this feature for a long time but never got around to implementing it. This feature actually closes the issue number 19.

Implemented in commit d8d6a8c067

@mattii I just released 0.9.3 with this feature

Fantastic! Thank you, @koskimas!

Was this page helpful?
0 / 5 - 0 ratings