Sequelize - Postgres - How to create new object from junction table - node.js

I'm trying to use a junction table model to create a new object in Sequelize. I have read through the docs pretty thoroughly and do not believe that this use case is covered, unless I am misunderstanding (which is always a possibility).
Docs:
https://sequelize.org/master/manual/assocs.html
https://sequelize.org/master/class/lib/associations/belongs-to-many.js~BelongsToMany.html
Example code:
import { Model as SQLModel } from 'sequelize';
class CarCompany extends SQLModel {};
class BodyStyle extends SQLModel {};
class Model extends SQLModel {};
// A car company can make multiple types of cars
CarCompany.hasMany(BodyStyle);
// A type of car can be made by multiple companies.
// We have a junction table to represent this relationship
BodyStyle.belongsToMany(CarCompany, { through: CarCompanyBodyStyles });
// Each model has only one type that it fits into and only one company that makes it
Model.belongsTo(CarCompanyBodyStyles);
// Now let's create some example car types
const convertible = new BodyStyle({ name: 'Convertible' });
const suv = new BodyStyle({ name: 'SUV' });
// Now let's create some example car companies
const toyota = new CarCompany({ name: 'Toyota' });
const ferrari = new CarCompany({ name: 'Ferrari' });
// Now let's specify which types of cars are made by each company
const toyotaConvertibles = toyota.addBodyStyle(convertible);
const toyotaSUVs = toyota.addBodyStyle(suv);
const ferrariConvertibles = ferrari.addBodyStyle(convertible);
// Now let's define some specific models
const solara = toyotaConvertibles.createModel({ name: 'Solara' });
const rav4 = toyotaSUVs.createModel({ name: 'RAV-4' });
const spider = ferrariConvertibles.createModel({ name: '488 Spider' });
// Now lets see some nested relational objects that we have created:
const toyotaModels = CarCompany.findByPk(
toyota.id,
{
include: [
{
all: true,
nested: true,
},
],
},
);
const ferrariModels = CarCompany.findByPk(
ferrari.id,
{
include: [
{
all: true,
nested: true,
},
],
},
);
console.log({ ferrariModels, toyotaModels });
What I was hoping to see is something like:
{
ferrariModels: {
bodyStyles: [
{
name: 'Convertible',
models: [
{ name: '488 Spider' },
],
},
],
},
toyotaModels: {
bodyStyles: [
{
name: 'SUV',
models: [
{ name: 'RAV-4' },
],
},
{
name: 'Convertible',
models: [
{ name: 'Solara' },
],
},
],
},
}
But instead I get an error:
TypeError: toyotaConvertibles.createModel is not a function
What am I doing wrong? How am I supposed to go about creating this type of relationship?

Related

Convert DB response from object to array

I have the following schema:
const mySchema = new mongoose.Schema({
name: String,
subscribers: [ { name: String } ]
})
const myModel = mongoose.model('User', mySchema, 'users')
and I have this code in one of my controllers:
const allOfHisSubscribers = await myModel.findById(req.params.id).select('subscribers')
I want the database response of the await myModel.findByid call to be:
[
{ name: 'x' },
{ name: 'y' },
{ name: 'z' },
]
However, the code above is returning:
{
subscribers: [
{ name: 'x' },
{ name: 'y' },
{ name: 'z' },
]
}
I don't want the JavaScript way of doing it, I know you can use something like Array.prototype.map to get the result, but I am using a middleware, so I can only use the mongoose or MongoDB way of doing it (if possible).
Got it :D, if not, let me know in the comments 🌹

Sequelize - trying to make models dynamic

I've been trying to automate the creation of my sequelize models by creating them with a generic model that I can pass definitions into, rather than creating a model file specifically for each one.
I have an array of model definitions which looks something like this:
const modelDefinitions = [
{
name: "User",
fieldDefinitions: [
{
name: "first_name",
label: "First Name",
column_type: Sequelize.DataTypes.STRING,
},
{
name: "last_name",
label: "Last Name",
column_type: Sequelize.DataTypes.STRING,
},
{
name: "email",
label: "Email",
column_type: Sequelize.DataTypes.STRING,
},
{
name: "password",
label: "Password",
restricted: true,
column_type: Sequelize.DataTypes.STRING,
},
],
},
{
name: "Audit",
fieldDefinitions: [
{
name: "ref",
column_type: Sequelize.DataTypes.STRING,
label: "Audit Ref",
},
{
name: "result",
column_type: Sequelize.DataTypes.STRING,
label: "Result",
},
{
name: "auditor_id",
column_type: Sequelize.DataTypes.INTEGER,
label: "Auditor",
},
],
},
];
When my array of models contains just one model it works perfectly fine, but when I have multiple, the GenericModel of the previously defined models is then "changed" to ne the last one in the list that was initialised.
I'm new to node so I think I'm either missing something or there's some sort of model caching happening, meaning that all instances of GenericModel become what it is initialised as last.
Please see my example below (the commented out code is what I used to use to define the models and the reduce is my new way of defining these)
// {
// User: User.init(sequelize, modelDef),
// Article: Article.init(sequelize, modelDef),
// Audit: Audit.init(sequelize, modelDef),
// Form: Form.init(sequelize, modelDef),
// };
const models = modelDefinitions.reduce((acc, modelDef) => {
return { ...acc, [modelDef.name]: GenericModel.init(sequelize, modelDef) };
}, {});
console.log({ models });
My console.log() returns the following - notice both are Group :
{
models: {
User: Group,
Group: Group
}
}
As you can see, what ever the last model is defined as, the previous ones inherit that instead of keeping what I defined them as originally.
But what I actually want is :
{
models: {
User: User,
Group: Group
}
}
If my list only had User in it, it works fine.
I managed to get this working in the end.
I think my issue was that my GenericModel was treated as a Singleton, so to get around this I changed GenericModel from extending the Sequelize.Model and instead made a new class with a contructor to consume my arguments and then created a method on the new class to return the sequelize model.
The main change there was instead of defining the models with GenericModel.init(), I defined them by calling sequelize.define(modelName, attributes, options)
so my map now looks like this :
const models = modelDefinitions.reduce((acc, modelDef) => {
return { ...acc, [modelDef.name]: new GenericModel(sequelize, modelDef).getDBModel() };
}, {});
and my class:
class TestModel {
constructor(sequelize, modelDef) {
this.sequelize = sequelize;
this.modelDef = modelDef;
this.modelName = modelDef?.name;
this.definitions = modelDef?.fieldDefinitions;
this.restrictedFieldList = this.definitions.filter((field) => field?.restricted).map((definition) => definition.name);
}
getDBModel() {
const model = this.sequelize.define(
this.modelName,
this.definitions.reduce((acc, definition) => {
return { ...acc, [definition.name]: definition.column_type };
}, {}),
{
defaultScope: {
attributes: {
exclude: this.restrictedFieldList,
},
},
sequelize: this.sequelize,
modelName: this.modelName,
}
);
return model;
}
}```

How do you seed a mongodb database such that the Keystone 5 CMS recognizes the many-to-many relationships?

Let's say I have two objects: Product and Seller
Products can have multiple Sellers.
A single Seller can sell multiple Products.
The goal is to write a seeding script that successfully seeds my MongoDB database such that Keystone.js's CMS recognizes the many-to-many relationship.
Schemas
Product.ts
import { text, relationship } from "#keystone-next/fields";
import { list } from "#keystone-next/keystone/schema";
export const Product = list({
fields: {
name: text({ isRequired: true }),
sellers: relationship({
ref: "Seller.products",
many: true,
}),
},
});
Seller.ts
import { text, relationship } from "#keystone-next/fields";
import { list } from "#keystone-next/keystone/schema";
export const Product = list({
fields: {
name: text({ isRequired: true }),
products: relationship({
ref: "Product.sellers",
many: true,
}),
},
});
KeystoneJS config
My keystone.ts config, shortened for brevity, looks like this:
import { insertSeedData } from "./seed-data"
...
db: {
adapter: "mongoose",
url: databaseURL,
async onConnect(keystone) {
console.log("Connected to the database!");
if (process.argv.includes("--seed-data")) {
await insertSeedData(keystone);
}
},
},
lists: createSchema({
Product,
Seller,
}),
...
Seeding Scripts (these are the files I expect to change)
I have a script that populates the database (seed-data/index.ts):
import { products } from "./data";
import { sellers } from "./data";
export async function insertSeedData(ks: any) {
// setup code
const keystone = ks.keystone || ks;
const adapter = keystone.adapters?.MongooseAdapter || keystone.adapter;
const { mongoose } = adapter;
mongoose.set("debug", true);
// adding products to DB
for (const product of products) {
await mongoose.model("Product").create(product);
}
// adding sellers to DB
for (const seller of sellers) {
await mongoose.model("Seller").create(seller);
}
}
And finally, data.ts looks something like this:
export const products = [
{
name: "apple",
sellers: ["Joe", "Anne", "Duke", "Alicia"],
},
{
name: "orange",
sellers: ["Duke", "Alicia"],
},
...
];
export const sellers = [
{
name: "Joe",
products: ["apple", "banana"],
},
{
name: "Duke",
products: ["apple", "orange", "banana"],
},
...
];
The above setup does not work for a variety of reasons. The most obvious is that the sellers and products attributes of the Product and Seller objects (respectively) should reference objects (ObjectId) and not names (e.g. "apple", "Joe").
I'll post a few attempts below that I thought would work, but did not:
Attempt 1
I figured I'd just give them temporary ids (the id attribute in data.ts below) and then, once MongoDB assigns an ObjectId, I'll use those.
seed-data/index.ts
...
const productIdsMapping = [];
...
// adding products to DB
for (const product of products) {
const productToPutInMongoDB = { name: product.name };
const { _id } = await mongoose.model("Product").create(productToPutInMongoDB);
productIdsMapping.push(_id);
}
// adding sellers to DB (using product IDs created by MongoDB)
for (const seller of sellers) {
const productMongoDBIds = [];
for (const productSeedId of seller.products) {
productMongoDBIds.push(productIdsMapping[productSeedId]);
const sellerToPutInMongoDB = { name: seller.name, products: productMongoDBIds };
await mongoose.model("Seller").create(sellerToPutInMongoDB);
}
...
data.ts
export const products = [
{
id: 0,
name: "apple",
sellers: [0, 1, 2, 3],
},
{
id: 1,
name: "orange",
sellers: [2, 3],
},
...
];
export const sellers = [
{
id: 0
name: "Joe",
products: [0, 2],
},
...
{
id: 2
name: "Duke",
products: [0, 1, 2],
},
...
];
Output (attempt 1):
It just doesn't seem to care about or acknowledge the products attribute.
Mongoose: sellers.insertOne({ _id: ObjectId("$ID"), name: 'Joe', __v: 0}, { session: null })
{
results: {
_id: $ID,
name: 'Joe',
__v: 0
}
}
Attempt 2
I figured maybe I just didn't format it correctly, for some reason, so maybe if I queried the products and shoved them directly into the seller object, that would work.
seed-data/index.ts
...
const productIdsMapping = [];
...
// adding products to DB
for (const product of products) {
const productToPutInMongoDB = { name: product.name };
const { _id } = await mongoose.model("Product").create(productToPutInMongoDB);
productIdsMapping.push(_id);
}
// adding sellers to DB (using product IDs created by MongoDB)
for (const seller of sellers) {
const productMongoDBIds = [];
for (const productSeedId of seller.products) {
productMongoDBIds.push(productIdsMapping[productSeedId]);
}
const sellerToPutInMongoDB = { name: seller.name };
const { _id } = await mongoose.model("Seller").create(sellerToPutInMongoDB);
const resultsToBeConsoleLogged = await mongoose.model("Seller").findByIdAndUpdate(
_id,
{
$push: {
products: productMongoDBIds,
},
},
{ new: true, useFindAndModify: false, upsert: true }
);
}
...
data.ts
Same data.ts file as attempt 1.
Output (attempt 2):
Same thing. No luck on the products attribute appearing.
Mongoose: sellers.insertOne({ _id: ObjectId("$ID"), name: 'Joe', __v: 0}, { session: null })
{
results: {
_id: $ID,
name: 'Joe',
__v: 0
}
}
So, now I'm stuck. I figured attempt 1 would Just Workâ„¢ like this answer:
https://stackoverflow.com/a/52965025
Any thoughts?
I figured out a solution. Here's the background:
When I define the schema, Keystone creates corresponding MongoDB collections. If there is a many-to-many relationship between object A and object B, Keystone will create 3 collections: A, B, and A_relationshipToB_B_relationshipToA.
That 3rd collection is the interface between the two. It's just a collection with pairs of ids from A and B.
Hence, in order to seed my database with a many-to-many relationship that shows up in the Keystone CMS, I have to seed not only A and B, but also the 3rd collection: A_relationshipToB_B_relationshipToA.
Hence, seed-data/index.ts will have some code that inserts into that table:
...
for (const seller of sellers) {
const sellerToAdd = { name: seller.name };
const { _id } = await mongoose.model("Seller").create(sellerToAdd);
// Product_sellers_Seller_products Insertion
for (const productId of seller.products) {
await mongoose
.model("Product_sellers_Seller_products")
.create({
Product_left_id: productIds[productId], // (data.ts id) --> (Mongo ID)
Seller_right_id: _id,
});
}
}
...

Updating an array in a discriminated model in mongoose

I have a model called Person which has a discriminator of "Worker" which gives it an additional locations field of an array.
I am trying to push an element onto the locations array without going through the fetch/modify/save method (so I can use updateMany later on to update several documents at the same time).
Why is that not happening in the code below? I have tried this with findByIdAndUpdate and findOneAndUpdate as well.
index.js:
const { connect } = require("mongoose");
const Person = require("./Person");
connect("mongodb://127.0.0.1:27017/test?gssapiServiceName=mongodb", {
useNewUrlParser: true,
}, async () => {
console.log("Database connected")
const person = await Person.create(
{
__t: "Worker",
name: "John",
locations: ["America"],
},
)
console.log(person);
// Outputs:
// {
// locations: [ 'America' ],
// _id: 5eba279663ecdbc25d4d73d4,
// __t: 'Worker',
// name: 'John',
// __v: 0
// }
await Person.updateOne(
{ _id: person._id }
{
$push: { locations: "UK" },
},
)
const updated = await Person.findById(person._id);
console.log(updated);
// (Updating "locations" was unsuccessful)
// Outputs:
// {
// locations: [ 'America' ],
// __t: 'Worker',
// _id: 5eba279663ecdbc25d4d73d4,
// name: 'John',
// __v: 0
// }
});
Person.js:
const { Schema, model } = require("mongoose");
const personSchema = Schema({
name: String,
});
const Person = model("Person", personSchema);
Person.discriminator(
"Worker",
Schema({
locations: [String],
})
);
module.exports = Person;
So it turns out you have to pass in the key (__t) when updating from the root Parent model and not the Worker model as the database does not know what fields a Worker would have.
Therefore, you can do the following:
await Person.updateOne(
{ _id : person._id, __t: "Worker" },
{ $push: { locations: "UK" } }
)
See more in this Github issue

nodejs + sequelize :: include where required false

i have the following hierarchy of objects:
user
parent (hasOne)
children (hasMany)
once i have a user object, im attempting to load the associated parent object along with some of its children. the following works:
user.getParent({
include: [{
model: Child
}]
}).then(function(parent) {
var children = parent.children;
});
but if i want to selectively load some of the parent's children, like so:
user.getParent({
include: [{
model: Child,
where: { gender: 'FEMALE' }
}]
}).then(function(parent) {
var daughters = parent.daughters;
});
if the parent has one or more daughters, the query works and i get the parent data along with all daughters' data. however, if the parent has only sons, the parent object returned is null..
i want that if the parent has no daughters, the parent object should still be resolved with children = null or children = []..
also, if i wanted to simply load a count of children while loading the parent, how can i go about doing that?
thanks
it turned out to be an issue with my own code.. sequelize fails to load a model if a nested include has a where clause that returns no models.. to ensure it doesnt fail completely, you can set a required: false clause along with the where clause.. this makes sequelize return a blank array instead of failing the load completely..
more info at https://github.com/sequelize/sequelize/issues/4019
required: false + where is inconsistent/buggy in sequelize 6.5.1 SQLite vs PostgreSQL
Edit: it was working fine as of sequelize v6.13.0, so it got fixed somewhere along the way.
This is one of the many many many Sequelize association bugs/inconsistencies that make me want to try and find another ORM at some point. Reported at: https://github.com/sequelize/sequelize/issues/13809
Consider the following example where we want to:
list all tags
in addition, determine if each tag has been assigned to a selected animal
Our where condition on the animal then is a simple "check if the animal is a specific animal", and can only return 0 or 1 entries therefore. But the same would be valid with any other more complex condition.
main.js
#!/usr/bin/env node
const assert = require('assert')
const path = require('path')
const { DataTypes, Sequelize } = require('sequelize')
let sequelize
if (process.argv[2] === 'p') {
sequelize = new Sequelize('tmp', undefined, undefined, {
dialect: 'postgres',
host: '/var/run/postgresql',
})
} else {
sequelize = new Sequelize({
dialect: 'sqlite',
storage: 'tmp.sqlite'
})
}
;(async () => {
const Animal = sequelize.define('Animal', {
species: { type: DataTypes.STRING },
})
const Tag = sequelize.define('Tag', {
name: { type: DataTypes.STRING },
})
Animal.belongsToMany(Tag, { through: 'AnimalTag' })
Tag.belongsToMany(Animal, { through: 'AnimalTag' })
await sequelize.sync({force: true})
const animal0 = await Animal.create({ species: 'bat' })
const animal1 = await Animal.create({ species: 'ant' })
const animal2 = await Animal.create({ species: 'dog' })
const tag0 = await Tag.create({ name: 'mammal' })
const tag1 = await Tag.create({ name: 'flying' })
const tag2 = await Tag.create({ name: 'aquatic' })
await animal0.addTag(tag0)
await animal0.addTag(tag1)
await animal2.addTag(tag1)
const animals = [animal0, animal1, animal2]
for (let animal of animals) {
let rows
rows = await Tag.findAll({
include: [{
model: Animal,
where: { id: animal.id },
// No effect.
//on: { id: animal.id },
through: {
// Same as putting the where outside of through.
//where: { AnimalId: animal.id },
},
required: false,
}],
order: [['name', 'ASC']],
})
console.error(animal.species);
console.error(rows.map(row => { return {
name: row.name,
animals: row.Animals.map(animal => animal.species),
} }))
}
})().finally(() => { return sequelize.close() })
package.json
{
"name": "tmp",
"private": true,
"version": "1.0.0",
"dependencies": {
"pg": "8.5.1",
"pg-hstore": "2.3.3",
"sequelize": "6.5.1",
"sqlite3": "5.0.2"
}
}
Run:
npm install
./main.js p
./main.js
The PotsgreSQL results are as expected:
bat
[
{ name: 'aquatic', animals: [] },
{ name: 'flying', animals: [ 'bat' ] },
{ name: 'mammal', animals: [ 'bat' ] }
]
ant
[
{ name: 'aquatic', animals: [] },
{ name: 'flying', animals: [] },
{ name: 'mammal', animals: [] }
]
dog
[
{ name: 'aquatic', animals: [] },
{ name: 'flying', animals: [] },
{ name: 'mammal', animals: [ 'dog' ] }
]
But the SQLite results are just plain wrong:
bat
[
{ name: 'aquatic', animals: [] },
{ name: 'flying', animals: [ 'bat' ] },
{ name: 'mammal', animals: [ 'bat', null ] }
]
ant
[
{ name: 'aquatic', animals: [] },
{ name: 'flying', animals: [] },
{ name: 'mammal', animals: [] }
]
dog
[
{ name: 'aquatic', animals: [] },
{ name: 'flying', animals: [] },
{ name: 'mammal', animals: [] }
]
I don't mind the null that much, I can work around that.
But dog not being a mammal, that I cannot work around.
Underlying queries
We can look at the underlying queries to guess what is going on.
PostgreSQL has queries of type:
SELECT
"Tag"."name",
"Animals"."species" AS "Animals.species",
FROM
"Tags" AS "Tag"
LEFT OUTER JOIN (
"AnimalTag" AS "Animals->AnimalTag"
INNER JOIN "Animals" AS "Animals" ON "Animals"."id" = "Animals->AnimalTag"."AnimalId"
) ON "Tag"."id" = "Animals->AnimalTag"."TagId"
AND "Animals"."id" = 1
ORDER BY
"Tag"."name" ASC;
SQLite has queries of type:
SELECT
`Tag`.`name`,
`Animals`.`species` AS `Animals.species`
FROM `Tags` AS `Tag`
LEFT OUTER JOIN `AnimalTag` AS `Animals->AnimalTag`
ON `Tag`.`id` = `Animals->AnimalTag`.`TagId`
LEFT OUTER JOIN `Animals` AS `Animals`
ON `Animals`.`id` = `Animals->AnimalTag`.`AnimalId`
AND `Animals`.`id`= 3
ORDER BY
`Tag`.`name` ASC;
So only the PostgreSQL query is the desired one because of the way it uses the subquery so that the:
ON "Tag"."id" = "Animals->AnimalTag"."TagId"
AND "Animals"."id" = 1
gets applied only to the outer LEFT OUTER JOIN.
The SQLite query produces undesired results, running it manually for each animal we get outputs:
bat
aquatic|
flying|bat
mammal|bat
mammal|
ant
aquatic|
flying|
mammal|
mammal|
dot
aquatic|
flying|
mammal|
mammal|dog
so what seems to happen is that the animal first result is null, then sequelize ignores it on the return, which would explain why we saw:
{ name: 'mammal', animals: [ 'bat', null ] }
for bat but:
{ name: 'mammal', animals: [] }
for dog.
The desired statement would have the AND on the first LEFT OUTER JOIN:
SELECT
`Tag`.`name`,
`Animals`.`species` AS `Animals.species`
FROM `Tags` AS `Tag`
LEFT OUTER JOIN `AnimalTag` AS `Animals->AnimalTag`
ON `Tag`.`id` = `Animals->AnimalTag`.`TagId`
AND `Animals->AnimalTag`.`AnimalId`= 1
LEFT OUTER JOIN `Animals` AS `Animals`
ON `Animals`.`id` = `Animals->AnimalTag`.`AnimalId`
ORDER BY
`Tag`.`name` ASC;
Workaround: super many to many
The best workaround I could come up so far is to use super many to many, this is often a good bet when the problem is that we need greater control about ON conditions:
#!/usr/bin/env node
const assert = require('assert')
const path = require('path')
const { DataTypes, Sequelize } = require('sequelize')
let sequelize
let logging = process.argv[3] === '1'
if (process.argv[2] === 'p') {
sequelize = new Sequelize('tmp', undefined, undefined, {
dialect: 'postgres',
host: '/var/run/postgresql',
logging,
})
} else {
sequelize = new Sequelize({
dialect: 'sqlite',
storage: 'tmp.sqlite',
logging,
})
}
;(async () => {
const AnimalTag = sequelize.define('AnimalTag')
const Animal = sequelize.define('Animal', {
species: { type: DataTypes.STRING },
})
const Tag = sequelize.define('Tag', {
name: { type: DataTypes.STRING },
})
AnimalTag.belongsTo(Animal)
Animal.hasMany(AnimalTag)
AnimalTag.belongsTo(Tag)
Tag.hasMany(AnimalTag)
Animal.belongsToMany(Tag, { through: AnimalTag })
Tag.belongsToMany(Animal, { through: AnimalTag })
await sequelize.sync({force: true})
const animal0 = await Animal.create({ species: 'bat' })
const animal1 = await Animal.create({ species: 'ant' })
const animal2 = await Animal.create({ species: 'dog' })
const tag0 = await Tag.create({ name: 'flying' })
const tag1 = await Tag.create({ name: 'mammal' })
const tag2 = await Tag.create({ name: 'aquatic' })
await animal0.addTag(tag0)
await animal0.addTag(tag1)
await animal2.addTag(tag1)
const animals = [animal0, animal1, animal2]
for (let animal of animals) {
let rows
rows = await Tag.findAll({
include: [{
model: AnimalTag,
where: { AnimalId: animal.id },
required: false,
include: [{
model: Animal,
}]
}],
order: [['name', 'ASC']],
})
console.error(rows.map(row => { return {
name: row.name,
animals: row.AnimalTags.map(animalTag => animalTag.Animal.species)
} }))
console.error();
}
})().finally(() => { return sequelize.close() })
produces the correct output for both PostgreSQL and Sequelize. The produced query is exactly what we wanted:
SELECT
"Tag"."id",
"Tag"."name",
"Tag"."createdAt",
"Tag"."updatedAt",
"AnimalTags"."createdAt" AS "AnimalTags.createdAt",
"AnimalTags"."updatedAt" AS "AnimalTags.updatedAt",
"AnimalTags"."AnimalId" AS "AnimalTags.AnimalId",
"AnimalTags"."TagId" AS "AnimalTags.TagId",
"AnimalTags->Animal"."id" AS "AnimalTags.Animal.id",
"AnimalTags->Animal"."species" AS "AnimalTags.Animal.species",
"AnimalTags->Animal"."createdAt" AS "AnimalTags.Animal.createdAt",
"AnimalTags->Animal"."updatedAt" AS "AnimalTags.Animal.updatedAt"
FROM
"Tags" AS "Tag"
LEFT OUTER JOIN "AnimalTags" AS "AnimalTags" ON "Tag"."id" = "AnimalTags"."TagId"
AND "AnimalTags"."AnimalId" = 3
LEFT OUTER JOIN "Animals" AS "AnimalTags->Animal" ON "AnimalTags"."AnimalId" = "AnimalTags->Animal"."id"
ORDER BY
"Tag"."name" ASC;
Tested on Ubuntu 21.10, PostgreSQL 13.5.

Resources