We're porting our api from C# to Loopback ^v3.19.0
and have run into a blocker.
Many of our models have shared properties, so we've created a base model "Base" which they inherit from.
{
"name": "Base",
"base": "PersistedModel",
"idInjection": true,
"options": {
"validateUpsert": true
},
"mixins": {
"Timestamp": {}
},
"properties": {
"created-by": {
"type": "number",
"postgresql": {
"columnName": "created_by"
}
},
"created-date": {
"type": "date",
"postgresql": {
"columnName": "created_on_utc"
}
},
"updated-by": {
"type": "number",
"postgresql": {
"columnName": "updated_by"
}
},
"updated-date": {
"type": "date",
"postgresql": {
"columnName": "updated_on_utc"
}
},
"soft-deleted": {
"type": "boolean",
"postgresql": {
"columnName": "is_deleted"
}
},
"deleted-by": {
"type": "number",
"postgresql": {
"columnName": "deleted_by"
}
},
"deleted-date": {
"type": "date",
"postgresql": {
"columnName": "deleted_on_utc"
}
},
"tenant-id": {
"type": "number",
"postgresql": {
"columnName": "tenant_id"
}
}
},
...
}
Inside the Timestamp mixin (our own), those properties get set accordingly
module.exports = function(Model, options) {
Model.observe('before save', function event(ctx, next) {
const token = ctx.options && ctx.options.accessToken;
const userId = token && token.userId;
const now = new Date().toISOString();
if (ctx.instance) {
ctx.instance['created-by'] = userId;
ctx.instance['created-date'] = now;
ctx.instance['updated-by'] = userId;
ctx.instance['updated-date'] = now;
} else {
if (ctx.data['soft-deleted'] &&
ctx.data['soft-deleted'] === true) {
ctx.data['deleted-by'] = userId;
ctx.data['deleted-date'] = now;
ctx.data['is-active'] = false;
}
ctx.data['updated-by'] = userId;
ctx.data['updated-date'] = now;
}
next();
});
};
This works great when creating a new model. It was working great for updates (PATCH /modelname/:id)
, but unexpectedly broke and we can't figure out why. (This is consistent across all the models that inherit from this Base
model.)
The mixin correctly sees the model and adds the updated properties like so
LoopbackJS | ************* 'before save' ctx.data **************
LoopbackJS | { 'is-active': false,
LoopbackJS | 'updated-by': 1,
LoopbackJS | 'updated-date': '2018-08-16T17:57:23.660Z' }
LoopbackJS | ************* END 'before save' ctx.data **************
But when loopback executes the update SQL, it somehow omits/removes the value for updated-by
? (2nd param should be 1
, not null
)
LoopbackJS | 2018-08-16T17:57:23.666Z loopback:connector:postgresql SQL: UPDATE "public"."asset_types" SET "is_active"=$1,"updated_by"=$2,"updated_on_utc"=$3::TIMESTAMP WITH TIME ZONE,"tenant_id"=$4 WHERE "id"=$5
LoopbackJS | Parameters: [false,null,"2018-08-16T17:57:23.660Z",1,5]
updated_by
in Postgres is nullable, so that shouldn't generate an error... but Loopback is sending a stringified function?
LoopbackJS | 2018-08-16T18:04:12.522Z loopback:connector:postgresql error: invalid input syntax for integer: "function () { [native code] }"
LoopbackJS | at Connection.parseE (/home/src/back-end/node_modules/pg/lib/connection.js:553:11)
LoopbackJS | at Connection.parseMessage (/home/src/back-end/node_modules/pg/lib/connection.js:378:19)
LoopbackJS | at TLSSocket.<anonymous> (/home/src/back-end/node_modules/pg/lib/connection.js:119:22)
LoopbackJS | at emitOne (events.js:115:13)
LoopbackJS | at TLSSocket.emit (events.js:210:7)
LoopbackJS | at addChunk (_stream_readable.js:264:12)
LoopbackJS | at readableAddChunk (_stream_readable.js:251:11)
LoopbackJS | at TLSSocket.Readable.push (_stream_readable.js:209:10)
LoopbackJS | at TLSWrap.onread (net.js:587:20)
If we don't touch the updated_by
column, the SQL is correct and updates.
Incidentally, if we soft-delete and the deleted_by
column is in play, the same thing happens there.
Feels like I'm spinning in circles here and probably overlooking something basic. Any suggestions?
EDIT
So it appears that it's not limited to a mixin... when we remove it completely and manually set the k:v pair in the payload (ie 'created-by': 1
) we still get the same error back from Postgres.
The root cause of this was due to incorrect relationships.
I created this as a gist, but pasting it here too in case it helps someone else.
It's a PostgreSQL
best-practice to use lowercase names, using snakecase if you need to. ie, my_column_name.
Also, since I'm using a JSON API client, I've installed the excellent loopback-component-jsonapi to handle the de/serialization stuff... but that just added additional complexities.
JSON API calls for dasherized property names. When you start with something like my-property-name
, Loopback or the PostgreSQL driver (doesn't really matter) collapses the dasherized property down to mypropertyname
by default.
This is bad... especially when you have an existing schema you're working with.
It's worse when you're working with relationships, because Loopback also appends the id
suffix by default, so now you have issues unless you happen to have a mypropertynameid
column.
Let's say we have a Customer
model. I needed endpoints that are lowercase (and dasherized, where applicable), so just change the plural to match here.
{
"name": "Customer",
"plural": "customers",
"base": "PersistedModel",
...
}
Inside of options.postgresql
, you can set a tableName
. Loopback will use the name
value by default, but remember PostgreSQL doesn't like CamelCase. You need to override this unless you use lowercase model names.
(It's a religious preference, but I like my tables to be plurals. Fight me.)
{
...
"options": {
"validateUpsert": true,
"postgresql": {
"tableName": "customers"
}
}
...
}
Back to the properties, use the postgresql.columnName
property to map to the correct column name in the db. If it's not a dasherized property name (ie status
) then you can ignore the postgresql.columnName
bit.
{
...
"properties": {
"is-active": {
"type": "boolean",
"default": false,
"postgresql": {
"columnName": "is_active"
}
}
}
}
Relationships can be a headache.
Let's say our Customer
has people who work there. To do a basic one-many relationship between the models...
{
...
"relations": {
"people": {
"type": "hasMany",
"model": "Person",
"foreignKey": "customer_id"
}
},
...
}
people
is the name of the relationship element of the JSON API payload.
A "gotcha" here for me was the foreignKey
property.
The Loopback docs say it's optional - and it is - but if you leave it out then it adds the id
suffix to the name (people
) and then looks for that column in your customers
table. That wasn't highlighted very well, but it was clear enough.
This part wasn't clear => I originally thought the foreignKey
value pointed to the property of the Person
model, so I had the dasherized customer-id
property here. That's incorrect. It's literally asking you for the database column name, which feels like a bit of an antipattern... In the properties you had to define a columnName
if you wanted to refer to the db columns under the ORM.
Also, note that the foreignKey
property is reused in relationships but it means different things to different type
contexts. In a hasMany
, it's asking "Which column there maps to the primary key here?"
Customer
model:
{
"name": "Customer",
"plural": "customers",
"base": "PersistedModel",
"options": {
"validateUpsert": true,
"postgresql": {
"tableName": "customers"
}
},
"properties": {
"name": {
"type": "string"
},
"is-active": {
"type": "boolean",
"default": false,
"postgresql": {
"columnName": "is_active"
}
}
},
"validations": [],
"relations": {
"people": {
"type": "hasMany",
"model": "Person",
"foreignKey": "customer_id"
}
},
"acls": [],
"methods": {}
}
The Person
model on the other end of the relationship.
The foreignKey
for a belongsTo
relationship is asking the opposite question... "Which property here maps to the primary key there?"
Also, if you have properties you don't want exposed (especially if you've inherited a model and don't want/need all those properties for whatever reason) then you can hide them with the hidden
element. See below.
{
"name": "Person",
"plural": "people",
"base": "User",
"idInjection": false,
"options": {
"validateUpsert": true,
"postgresql": {
"tableName": "people"
}
},
"hidden": [
"emailVerified",
"realm",
"username",
],
"properties": {
"first-name": {
"type": "string",
"postgresql": {
"columnName": "first_name"
}
},
"last-name": {
"type": "string",
"postgresql": {
"columnName": "last_name"
}
},
"email": {
"type": "string"
},
...
},
"validations": [],
"relations": {
"customer": {
"type": "belongsTo",
"model": "Customer",
"foreignKey": "customer_id"
}
},
"acls": [],
"methods": {}
}