13

I'm using node v9.5, sequelize v4.33 (postgres dialect).

I have two first-class models: Driver (specific people) and Car (generic make+model combinations). Thus far, they've been connected by a many-to-many join table. Now I want to start tracking additional properties on that join table, but am having trouble declaring these relationships so they actually work.

const Driver = sqlz.define('Driver', {
    id: { primaryKey: true, type: DataTypes.UUID },
    name: DataTypes.string
})

const Car = sqlz.define('Car', {
    id: { primaryKey: true, type: DataTypes.UUID },
    make: DataTypes.string,
    model: DataTypes.string
})

// old associations; worked great when requirements were simpler
Driver.belongsToMany(Car, {
    through: 'DriverCar',
    as: 'carList',
    foreignKey: 'driverId'
})

Car.belongsToMany(Driver, {
    through: 'DriverCar',
    as: 'driverList',
    foreignKey: 'carId'
})

Now I want to begin tracking more information about the relationship between a car and its driver, like the color of that specific car.

Step 1: I update the migration script, adding a new column to the join table like so:

queryInterface.createTable( 'DriverCar', {
    driverId: {
        type: sqlz.UUID,
        allowNull: false,
        primaryKey: true,
        references: {
            model: 'Driver',
            key: 'id'
        }
    },
    carId: {
        type: sqlz.UUID,
        allowNull: false,
        primaryKey: true,
        references: {
            model: 'Car',
            key: 'id'
        }
    },
    createdAt: {
        type: sqlz.DATE,
        allowNull: false
    },
    updatedAt: {
        type: sqlz.DATE,
        allowNull: false
    },

    // new column for join table
    color: {
      type: Sequelize.STRING
    }
})

Step 2: I define a new sqlz model for DriverCar:

const DriverCar = sqlz.define('DriverCar', {
    color: DataTypes.string
})

(I assume I only need to define the interesting properties, and that driverId and carId will still be inferred from the associations that will be defined.)

Step 3: I need to update the associations that exist among Driver, Car, and DriverCar.

This is where I'm stuck. I have attempted updating the existing associations, like so:

Driver.belongsToMany(Car, {
    through: DriverCar, // NOTE: no longer a string, but a reference to new DriverCar model
    as: 'carList',
    foreignKey: 'driverId'
})

Car.belongsToMany(Driver, {
    through: DriverCar, // NOTE: no longer a string, but a reference to new DriverCar model
    as: 'driverList',
    foreignKey: 'carId'
})

This executes without error, but the new color property is not fetched from the join table when I try driver.getCarList(). (Sqlz is configured to log every SQL statement, and I have verified that no properties from the join table are being requested.)

So, instead, I tried spelling out this relationship more explicitly, by associating Driver to DriverCar, and then Car to DriverCar:

// Driver -> Car
Driver.hasMany(DriverCar, {
    as: 'carList',
    foreignKey: 'driverId'
})

// Car -> Driver
Car.hasMany(DriverCar, {
    foreignKey: 'carId'
})

I also tell sqlz that DriverCar won't have a standard row id:

DriverCar.removeAttribute('id')

At this point, requesting a Driver's carList (driver.getCarList()) seems to work, because I can see join table props being fetched in SQL. But saving fails:

driverModel.setCarList([ carModel1 ])

UPDATE DriverCar
SET "driverId"='a-uuid',"updatedAt"='2018-02-23 22:01:02.126 +00:00'
WHERE "undefined" in (NULL)

The error:

SequelizeDatabaseError: column "undefined" does not exist

I assume this error is occurring because sqzl doesn't understand the proper way to identify rows in the join table, because I've failed to establish the necessary associations. And frankly, I'm not confident I've done this correctly; I'm new to ORMs, but I was expecting I'd need to specify 4 assocations:

  1. Driver -> DriverCar
  2. DriverCar -> Car
  3. Car -> DriverCar
  4. DriverCar -> Driver

To recap: I have 2 first-class entities, joined in a many-to-many relationship. I'm trying to add data to the relationship, have discovered that the ORM requires defining those associations differently, and am having trouble articulating the new associations.

Tom
  • 8,509
  • 7
  • 49
  • 78
  • This is one of the things where sequelize doesn't shine much. Still you should be able to find Driver and include Cars and it should bring all the fields. Do it in the query (using include) instead of using the magic method – yBrodsky Feb 26 '18 at 17:04
  • @yBrodsky: Thanks for the tip. I believe I know what you mean by "instead of using the magic method," but I'll have to do some more research to cash out your suggestion. – Tom Feb 26 '18 at 18:10
  • I should add, this issue on sequelize suggests the `include` approach won't work: https://github.com/sequelize/sequelize/issues/9094 – Tom Feb 26 '18 at 18:11
  • Still no luck here. If anyone finds this, I'd still appreciate help. – Tom Mar 06 '18 at 21:59
  • https://pastebin.com/qT9D4KXE This doesn't work? – yBrodsky Mar 07 '18 at 13:14

1 Answers1

27

A note about your aliases

Before going to the answer, I would like to point out that your choice of aliases (carList and driverList) could be better, because although the auto-generated sequelize methods .setCarList() and .setDriverList() do make sense, the methods .addCarList(), .addDriverList(), .removeCarList() and .removeDriverList() are nonsense, since they take only a single instance as a parameter, not a list.

For my answer, I won't use any aliases, and let Sequelize default to .setCars(), .setDrivers(), .addCar(), .removeCar(), etc, which make much more sense to me.


Example of working code

I've made a 100% self-contained code to test this. Just copy-paste it and run it (after running npm install sequelize sqlite3):

const Sequelize = require("sequelize");
const sequelize = new Sequelize({ dialect: 'sqlite', storage: 'db.sqlite' });

const Driver = sequelize.define("Driver", {
    name: Sequelize.STRING
});
const Car = sequelize.define("Car", {
    make: Sequelize.STRING,
    model: Sequelize.STRING
});
const DriverCar = sequelize.define("DriverCar", {
    color: Sequelize.STRING
});
Driver.belongsToMany(Car, { through: DriverCar, foreignKey: "driverId" });
Car.belongsToMany(Driver, { through: DriverCar, foreignKey: "carId" });

var car, driver;

sequelize.sync({ force: true })
    .then(() => {
        // Create a driver
        return Driver.create({ name: "name test" });
    })
    .then(created => {
        // Store the driver created above in the 'driver' variable
        driver = created;

        // Create a car
        return Car.create({ make: "make test", model: "model test" });
    })
    .then(created => {
        // Store the car created above in the 'car' variable
        car = created;

        // Now we want to define that car is related to driver.
        // Option 1:
        return car.addDriver(driver, { through: { color: "black" }});

        // Option 2:
        // return driver.setCars([car], { through: { color: "black" }});

        // Option 3:
        // return DriverCar.create({
        //     driverId: driver.id,
        //     carId: car.id,
        //     color: "black"
        // });
    })
    .then(() => {
        // Now we get the things back from the DB.

        // This works:
        return Driver.findAll({ include: [Car] });

        // This also works:
        // return car.getDrivers();

        // This also works:
        // return driver.getCars();
    })
    .then(result => {
        // Log the query result in a readable way
        console.log(JSON.stringify(result.map(x => x.toJSON()), null, 4));
    });

The code above logs as expected (as I would expect, at least):

[
    {
        "id": 1,
        "name": "name test",
        "createdAt": "2018-03-11T03:04:28.657Z",
        "updatedAt": "2018-03-11T03:04:28.657Z",
        "Cars": [
            {
                "id": 1,
                "make": "make test",
                "model": "model test",
                "createdAt": "2018-03-11T03:04:28.802Z",
                "updatedAt": "2018-03-11T03:04:28.802Z",
                "DriverCar": {
                    "color": "black",
                    "createdAt": "2018-03-11T03:04:28.961Z",
                    "updatedAt": "2018-03-11T03:04:28.961Z",
                    "driverId": 1,
                    "carId": 1
                }
            }
        ]
    }
]

Note that there is no secret. Observe that the extra attribute that you're looking for, color, comes nested in the query result, not in the same nesting level of the Car or Driver. This is the correct behavior of Sequelize.

Make sure you can run this code and get the same result I do. My version of Node is different but I doubt that could be related to anything. Then, compare my code to your code and see if you can figure out what is causing you problems. If you need further help, feel free to ask in a comment :)


A note about many-to-many relationships with extra fields

Since I stumbled myself upon problems with this, and this is related to your situation, I thought I should add a section in my answer alerting you to the "trap" of setting up an overcomplicated many-to-many relationship (it's a lesson that I learned myself after struggling for a while).

Instead of repeating myself, I will just add a brief quote of what I said in Sequelize Issue 9158, and add links for further reading:

Junction tables, the tables that exist in relational databases to represent many-to-many relationships, initially have only two fields (the foreign keys of each table defining the many-to-many relationship). While it's true that it's possible to define extra fields/properties on that table, i.e. extra properties for the association itself (as you put in the issue title), care should be taken here: if it's getting overcomplicated, it's a sign that you should "promote" your junction table to a full-fledged entity.

Further reading:

Pedro A
  • 3,989
  • 3
  • 32
  • 56
  • This seems pretty solid. I am going to need time to study it. Thank you. – Tom Mar 12 '18 at 14:44
  • @Tom - no problem, take your time. If you need further clarification/help, feel free to comment (: – Pedro A Mar 12 '18 at 17:25
  • I take your point about the aliases making for odd auto-generated method names. I'm new to sqlz, and it has been hard to find a complete list of the mixins that are automatically added to classes for each kind of association, which has frustrated the task of choosing names. All I know for sure is that I do not under any circumstances want it to be "intelligently" pluralizing words. If you have a good reference for those mixins (the source scatters them about), I'd like to take another crack at the plurals. – Tom Mar 13 '18 at 20:29
  • 1
    @Tom - Sorry to take so long to reply. For the plural things you said, see my questions & answers [here](https://stackoverflow.com/q/49459596) and [here](https://stackoverflow.com/q/49467654). But I suggest that you attack your problem in parts. First, forget about the aliases, see if you can make your code work without any aliases. Then, after you're done with that, put your aliases again and see what happens. If you need further help, please let me know. If you make it work, let me know as well and if I was helpful, click the green checkmark :) – Pedro A Mar 25 '18 at 21:03
  • Sorry for the delay. I got stuff working soon after your response, but needed time to compare my fix to your answer to see if anything was omitted; your answer is complete! Thanks very much! – Tom Apr 25 '18 at 22:19
  • @PedroA I have a related question regarding the creation of such many-to-many records with extra fields on the junction table. I wonder if there is a way to create all 3 at once trough some specific data structure and includes mixture? My question is here: https://stackoverflow.com/questions/52784472/sequelize-set-extra-attributes-of-through-model-on-create?noredirect=1#comment92494153_52784472 Thanks! – bluehipy Oct 13 '18 at 08:22
  • 1
    @bluehipy Hello, I will take a look tomorrow :) – Pedro A Oct 13 '18 at 11:51
  • 1
    do not use `sequelize.sync({ force: true })` on a production db with data in it, use a migration file instead – Kenzo Feb 06 '20 at 00:52
  • Dude, there was a production with a polymorphic relationship. And thanks to your help, I completed the missing piece. Cheers – HyopeR Sep 17 '21 at 09:47
  • @HyopeR Nice!! Cheers :) – Pedro A Sep 17 '21 at 12:33