-
Notifications
You must be signed in to change notification settings - Fork 1.1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(repository): rejects CRUD operations for navigational properties #4148
Conversation
2fba490
to
9043d23
Compare
92a6e0d
to
8a7f78d
Compare
...itory-tests/src/crud/relations/acceptance/has-many-inclusion-resolver.relation.acceptance.ts
Outdated
Show resolved
Hide resolved
+1 to find a better name than Originally, I wanted to introduce The idea is to have a protected Repository method that's called whenever a CRUD method needs to convert data supplied by the user to data passed down to juggler. This new method can modify the data (e.g. by returning a new data object), but also perform validations (e.g. reject invalid data by throwing an error). It makes me wonder, is this something we should aim for as part of this work? |
const data: AnyObject = new this.entityClass(entity); | ||
|
||
const def = this.entityClass.definition; | ||
for (const r in def.relations) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The way how we detect the navigational property is reasonable to me 👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Improvement to consider: not the scope of this PR, if the same check applies to all CRUD functions, maybe we should define it as an interceptor?
); | ||
} | ||
} | ||
if (options.replacement === true) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, why do we need to deal with the idProperty
in ensurePersistable
? I don't think for replacement people can include the id from input, we should prevent the id field by excluding it from the request body schema defined in controller.
See the type used for request body, I think the solution should be changing it to sth like
@requestBody({
content: {
'application/json': {
schema: getModelSchemaRef(Todo, {title: 'NewTodo', exclude: ['id']}),
},
},
})
todo: Omit<Todo, 'id'>,
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I just tested out the code with @bajtos 's fix of ObjectId
: loopbackio/loopback-connector-mongodb#553. I think I can remove the options.replacement
part cause it's fixed 👏
And yes I agree that id
should be excluded from update/replace
methods at both controller and repository level. Currently, update/replacement
methods take id
property and ignore it in the operation. The result is correct but I think it should throw errors when id
is included.
So I am thinking, as Miroslav suggested above, the new function can perform validations such as checking nav properties, and checking if id is included for update/replace
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I just tested out the code with @bajtos 's fix of ObjectId: loopbackio/loopback-connector-mongodb#553. I think I can remove the options.replacement part cause it's fixed 👏
Awesome!
And yes I agree that id should be excluded from update/replace methods at both controller and repository level. Currently, update/replacement methods take id property and ignore it in the operation. The result is correct but I think it should throw errors when id is included.
I believe it was an intentional design to require id
property to be present in the payload of replaceById
request. This operation does exactly what is says - replaces the entire model instance in the database with the supplied data. This is important when free-form properties come to play, because it's not possible to remove a property via patch
calls. The call patchById(id, {someProp: undefined})
is a no-op, because undefined
cannot be represented in JSON.
I feel it's also better UX when the id
is allowed in the model data sent to both patch
and replace
methods. Clients often store the model instance in a variable, then make some change to some properties (e.g. via UI) and then want to persist that changes. If the PK property is forbidden in the payload, then the clients must do extra work to remove it.
@agnes512 The added tests LGTM in general(I will do a line by line review later), left two comments about the design of #4148 (comment) feel free to improve it in a separate story or at least a separate PR #4148 (comment) can be done in a separate story. |
@bajtos Could you elaborate more about ^? are you looking for something like an |
af3410e
to
da0fdc4
Compare
The use case I have in mind is storing certain properties as encrypted in the database, see #2095.
|
skipIf( | ||
!features.hasRevisionToken, | ||
it, | ||
'returns inclusions after running save() operation', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you please provide more details on why are these new test cases skipped? IIUC, you are running these tests only for CouchDB/Cloudant and skipping them for all other databases (memory, PostgreSQL, MongoDB). The code in the test looks legit to me, I find it suspicious that it does not work on so many database types.
Ideally, I'd like to find a way how to enable these tests for all databases we support.
If that's not possible or if it requires too much effort, then I would like you to add code comments near !features.hasRevisionToken
line to explain why each test is skipped.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test should run on all databases except Cloudant
cause it needs the _rev
property. I will add comments to clarify.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@agnes512 thank you for replying in time, I am running these 2 tests and I think I understand why they fail for Cloudant and mongodb, fixing it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@bajtos The test case here skips Cloudant connector because the Customer model in fixtures doesn't have _rev
property defined.
skipIf(features.hasRevisionToken, testcase){//tests}
means when features.hasRevisionToken
is true(which only applies for the cloudant connector), the test will be skipped.
I am not very familiar with the shared test case, so agreed with Agnes's solution,
if you want to enable same tests for cloudant, I can add them in a separate PR (within same story).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@bajtos The test fails for mongodb because the id type is object
not string, therefore fails at the check in https://github.com/strongloop/loopback-datasource-juggler/blob/master/lib/dao.js#L2621, hmm...any suggestion on how to fix it?
Update, just talked to Agnes, she points me to the fix code commented in https://github.com/strongloop/loopback-next/blob/49ecf1b86f058576404f28c88d4650651fc31419/packages/repository/src/repositories/legacy-juggler-bridge.ts#L580-L585 :)
Do you agree on the solution? If it is acceptable I will recover this code. (Then I believe all tests will pass)
@bajtos Thank you for the explanation. I am looking at your original proposal as class DefaultCrudRepository<T, ID> /*...*/{
protected async entityToData(entity: DataObject<T>, options?: Options): DataObject<T> {
// "persist hook" - no-op by default
return entity;
}
protected async dataToEntity(data: DataObject<T>, options?: Options): DataObject<T> {
// "load hook" - no-op by default
return this.toEntity(data);
}
async create(entity: DataObject<T>, options?: Options): Promise<T> {
const data = await this.entityToData(entity, options);
const model = await ensurePromise(this.modelClass.create(data, options));
const result = await this.dataToEntity(model);
return result;
}
// etc.
} Do you mean we implement no-op // Check navigation properties
// This check will be added in EACH created Repository that extends `DefaultCRUDRepository`
// as the built-in implementation for `entityToData`
// If developer wants to custom the logic how would they do it? Create a custom
// `BaseCRUDRepository` with their implementations of `entityToData` and `dataToEntity`,
// then make the created Repositories extend the `BaseCRUDRepository`?
protected async entityToData(entity: DataObject<T>, options?: Options): DataObject<T> {
const data: AnyObject = new this.entityClass(entity);
const def = this.entityClass.definition;
for (const r in def.relations) {
const relName = def.relations[r].name;
if (relName in data) {
throw new Error(
`Navigational properties are not allowed in model data (model "${this.entityClass.modelName}" property "${relName}")`,
);
}
}
} |
I don't think the navigation property check belongs to generated repositories, IMO this check should be executed by When I was writing those comments for I think we should change the default implementation of class DefaultCrudRepository<T, ID> /*...*/{
// "persist hook"
protected async entityToData(entity: DataObject<T>, options?: Options): DataObject<T> {
ensurePersistable(entity, options);
return entity;
}
// ...
} Please note the name |
class DefaultCrudRepository<T, ID> /*...*/{
// "persist hook"
protected async entityToData(entity: DataObject<T>, options?: Options): DataObject<T> {
ensurePersistable(entity, options);
return entity;
}
// ...
} Cool thank you 👍 now I understand your plan |
This is tricky, I'll need to find more time to look into the issue in more depth. My initial feeling is that we are trying to work around a flaw in the way how repository code and the shared test suite is designed. Instead of adding more and more workarounds, I would prefer to find a better design that will not require these hacks.
Ideally, when we run the test suite against MongoDB, all PK and FK values should be always strings. This is typically achieved by defining the properties as
This is similar to the discussion we had in #3968, see #3968 (comment) IMO, we should find a way how to modify Customer model to include IMO, the current way of setting up test models in
Ideally, One of the problems I see in the current setup is that models used by relations are hard-coded in TypeScript source files (see e.g. IMO, our life would be much easier if model classes were created dynamically by a factory function, as can be see in the non-relation tests - e.g. here: Anyhow, I understand such change may require more effort that we want to invest right now. Even if we decide to do it, it belongs to a new pull request, not here. In that light, let's try to find a way how to patch the current test setup so that we don't have to implement workarounds in the production code, and instead have the test models use the right model settings & property definitions, ones that our users should apply too. I think that means we need to add Thoughts? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great progress 👏
...itory-tests/src/crud/relations/acceptance/has-many-inclusion-resolver.relation.acceptance.ts
Outdated
Show resolved
Hide resolved
...itory-tests/src/crud/relations/acceptance/has-many-inclusion-resolver.relation.acceptance.ts
Outdated
Show resolved
Hide resolved
...itory-tests/src/crud/relations/acceptance/has-many-inclusion-resolver.relation.acceptance.ts
Outdated
Show resolved
Hide resolved
const reheatedPizza = toJSON(pizza); | ||
|
||
// coerce the id for mongodb connector to pass the id equality check | ||
// in juggler's replaceById function |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this still necessary? I believe that if we configure all PK properties with {useDefaultIdType: false, mongodb: {dataType: 'ObjectId'}
, then juggler will take care of the conversion from ObjectID to string and vice versa.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
removed const reheatedPizza = toJSON(pizza);
found.name = 'updated name'; | ||
found.orders.push(wrongOrder); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it a requirement to modify find.orders
array? Shouldn't the check be triggered even when the array remains the same as returned by customerRepo.findById
? If the behavior is different depending on whether the array was changed or not, then we should have two tests to cover both variants.
@@ -32,6 +32,12 @@ export class Customer extends Entity { | |||
}) | |||
name: string; | |||
|
|||
@property({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please add a comment to explain that this property is needed for CouchDB/Cloudant tests.
delete(entity: T, options?: Options): Promise<void> { | ||
async delete(entity: T, options?: Options): Promise<void> { | ||
// perform persist hook | ||
await this.entityToData(entity, options); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am not sure if delete
is supposed to reject requests when entity
contains navigational properties, but I don't have a strong opinion on what's better.
* @param entity The entity passed from CRUD operations' caller. | ||
* @param options | ||
*/ | ||
protected async entityToData<R extends T>( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since entityToData
is a new public API, we should have tests to describe and verify the intended behavior:
entityToData
is called for create/patch/replace operations (probably fordelete
too)- the value returned by
entityToData
is sent to persisted model, i.e. modifications made by this function are applied
Thoughts?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
unit test for entityToData
is added. It is invoked by create()
method cause it's protected.
* @param entity The entity passed from CRUD operations' caller. | ||
* @param options | ||
*/ | ||
protected ensurePersistable<R extends T>( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it a good idea to expose this method as protected
? We already have entityToData
, I think that's good enough, and thus ensurePersistable
can be private
.
Thoughts?
@bajtos Thanks for the review and very detailed suggestion!
I tried
👍 I updated in the 2nd last commit, and all tests pass EXCEPT the one failure in the mongodb test suite. The changes in the last commit are actually quite broken. |
2729720
to
4311a04
Compare
425512d
to
a476fbe
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see the tests are passing for all connectors, I guess it's time to move on and create follow-up issues to clean up the shared tests.
We already have an issue for improving ObjectID handling: #3456 Can you please post a comment there with a link to this PR and explanation what issues we need to fix?
I think there are two more issues to open: one to enable skipped tests on Cloudant, another to make the shared models smaller, so that shippment_id
property is present only in tests verifying the use case where the DB foreign key is different from the LB4 property name.
PTAL at the comments I posted during my last review, I think few of them are not addressed yet.
Other than that, I am ok to land this pull request mostly as it is now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🚢 LGTM
f55e2ac
to
95ab583
Compare
95ab583
to
ccc35fe
Compare
Hi there, I am trying to perform a crud operation (post and patch) without any navigational property, but the error shows off, I can't understand why... To make it short, I have a simple model : class Bag extends Entity {
@property() id;
@property() name;
@hasMany(() => Stuff)
stuffs?: Array<Stuff> = [];
} In my repository I do something like: this.updateById(1, {name: 'new bag'); And I got this error : Error: Navigational properties are not allowed in model data (model "Bag" property "stuffs"); Am I missing something ? --- EDIT --- |
@CedricBad with the hasMany decorator,
if a Please let me know if you have any questions. |
I was looking for a way to have my stuffs always included, even if there is none. That is why I assigned an empty array to the stuffs. But then I have the error mentionned aboved, so I guess there is no way to have a default value assigned to a relationnal item. |
implements #3439.
please review it with "Hide whitespace changes"
Add two new protected methods:
DefaultCrudRepository.ensurePersistable
andDefaultCrudRepository.entityToData
.This new function checks
create*(), update*(), save(), delete*(), and replaceById()
methods. Throws when the target data ( the one we are going to create, update) contains navigational properties:in the
ensurePersistable
, the//FIXME
from @bajtos says mongoDB would breakreplaceById
with ObjectID issue if we applyensurePersistable
toreplaceIdBy
.By debugging, I think it's caused by replacing the id project (
ObjectID
type) to a string type id.So in my proposal, I remove the id property from the target data for.replaceById
method inensurePersistable
since we don't need it anywayI decide to open a follow up issue to handle the issue cause it relates to
dao.js
andmongo.js
files, which is not a easy fix for me according to my knowledge of the code base 😶tests for create/update with nav properties are added to
legacy-juggler-bridge.unit.ts
(for basic functionality) andhas-many.relation.acceptance.ts
( against real DBs).tests for replacement(
save
,replaceById
) are added tohas-many-inclusion-resolver.relation.acceptance.ts
to check if the new instance can be found with inclusion ( these tests are skip for Cloudant as it needs_rev
token).Checklist
👉 Read and sign the CLA (Contributor License Agreement) 👈
npm test
passes on your machinepackages/cli
were updatedexamples/*
were updated👉 Check out how to submit a PR 👈
Followups
Commented the
objectId
issue in #3456Simplify fixtures and tests #4249
Enable replaceById + inclusion tests run on Cloudant #4251