fix(repository-json-schema): resolve the circular reference#2690
fix(repository-json-schema): resolve the circular reference#2690
Conversation
74edbba to
3b350c4
Compare
3b350c4 to
59447bd
Compare
|
|
||
| export interface JsonSchemaOptions { | ||
| // Track the models/titles that have been visited. | ||
| visited?: string[]; |
There was a problem hiding this comment.
Let's use Set instead to include visited TypeScript classes.
There was a problem hiding this comment.
+1 to use a Set instead of an array.
FYI: In a very near feature, we will be emitting multiple schemas for a single model class:
- Vanilla model schema as today
- Model schema including relations
- Model schema excluding certain properties (
id,_rev, etc.)
Using TypeScript classes as the key will not work for that. I think it's better to use Schema title because that has to be unique. See #2653, #2652 and https://github.com/strongloop/loopback-next/pull/2646/files#diff-432476dd2d984e441d8b4e0af39a39be.
There was a problem hiding this comment.
@bajtos The set tracks title instead of the class name, see code
Or do you want to store the whole generated schema in the options.visited?
That would not be possible IMO, since generating the referenced definitions happen in the iteration, before all the properties are visited.
There was a problem hiding this comment.
The set tracks title instead of the class name
Cool, that should work well 👍
do you want to store the whole generated schema in the options.visited?
IIRC, in my PoC, I was storing the whole generated schema in options.visited to allow me to load it from options.visited and put it to result.definitions.
|
Please run |
|
|
||
| export interface JsonSchemaOptions { | ||
| // Track the models/titles that have been visited. | ||
| visited?: string[]; |
There was a problem hiding this comment.
+1 to use a Set instead of an array.
FYI: In a very near feature, we will be emitting multiple schemas for a single model class:
- Vanilla model schema as today
- Model schema including relations
- Model schema excluding certain properties (
id,_rev, etc.)
Using TypeScript classes as the key will not work for that. I think it's better to use Schema title because that has to be unique. See #2653, #2652 and https://github.com/strongloop/loopback-next/pull/2646/files#diff-432476dd2d984e441d8b4e0af39a39be.
|
|
||
| result.title = meta.title || ctor.name; | ||
| const isVisited = | ||
| options && options.visited && options.visited.includes(result.title!); |
There was a problem hiding this comment.
Let's set options to {} by default so that we don't have to check for its existence.
export function modelToJsonSchema(
ctor: Function,
options: JsonSchemaOptions = {}
): JSONSchema {
// ...
const isVisited =
options.visited && options.visited.includes(result.title!);
// ...
}|
|
||
| result.title = meta.title || ctor.name; | ||
| const isVisited = | ||
| options && options.visited && options.visited.includes(result.title!); |
There was a problem hiding this comment.
Please avoid using ! operator to silence TypeScript checks.
const title = meta.title || ctor.name;
const isVisited = options.visited && options.visited.includes(title);
result.title = title;| // If the model is already visited in the call stack, it implies one or more | ||
| // of its referenced properties refer back to it. | ||
|
|
||
| if (isVisited) break; |
There was a problem hiding this comment.
I am confused. Why are we checking isVisited deep inside the loop iterating over model properties?
In the spike 2256435, I was able to return early in the schema generation. Once we know the schema title, we can check whether we have already seen such schema and immediately return.
What am I missing?
The visited field is an array tracks the title of visited items instead of caching the schema. This is because
getJsonSchemais called during each iteration of the property schema generation, not after the completed model schema(without definitions) get built.
This should not matter. In our cache (both visited and metadata-based cache used by getJsonSchema), we are storing a schema object by reference. Even if we initially store any empty schema object first with no properties, the code executed by modelToJsonSchema will eventually fill in those properties directly in the cached object.
There was a problem hiding this comment.
You are right. My expected JSON schema was wrong, I thought the definitions field from modelToJsonSchema(Category) should contain the schema of Category itself.
I refactored the code to return an empty array when the model is visited, which is still slightly different from your PoC.
In our cache (both visited and metadata-based cache used by getJsonSchema), we are storing a schema object by reference. Even if we initially store any empty schema object first with no properties, the code executed by modelToJsonSchema will eventually fill in those properties directly in the cached object.
Sounds reasonable to me...I am trying the code in PoC while run into the circular error again. I might miss something, need more time to figure it out.
|
|
||
| if (isVisited) break; | ||
|
|
||
| // Use object assign to avoid polluting the original `options`. |
There was a problem hiding this comment.
WIP, will add it in the next commit.
| }; | ||
|
|
||
| it('handles circular references', () => { | ||
| const schema = modelToJsonSchema(Category); |
There was a problem hiding this comment.
Please add a test to verify getJsonSchema too.
There was a problem hiding this comment.
I didn't realize getJsonSchema is the entry point of the schema generation, thought it starts with modelToJsonSchema.
Good catch! Tests added.
.vscode/settings.json
Outdated
| { | ||
| "editor.rulers": [80], | ||
| "editor.rulers": [ | ||
| 80 |
There was a problem hiding this comment.
@bajtos The format is changed when auto save. And if you check the other settings, breaking it into multiple the lines is the expected format.
I can try to move the change into a separate commit.
There was a problem hiding this comment.
The format is changed when auto save. And if you check the other settings, breaking it into multiple the lines is the expected format.
Fair enough 👍
I can try to move the change into a separate commit.
Yes please. A new PR would be even better, that way we can land this small fix sooner.
There was a problem hiding this comment.
It turns out it's just my vscode's weird settings.
The other settings are broken down into multiple lines due to the max characters per line.
Reverting.
| const propSchema = getJsonSchema(referenceType, getJsonSchemaOptions); | ||
|
|
||
| if (propSchema && Object.keys(propSchema).length > 0) { | ||
| result.definitions = result.definitions || {}; |
There was a problem hiding this comment.
One idea: since we are effectively storing the schema of visited models in result.definitions; if we need to access schema of a model that has been already visited, we can perhaps obtain it from those definitions object? Or maybe we should remove options.visited and pass the definitions key-value map in options instead?
There was a problem hiding this comment.
One idea: since we are effectively storing the schema of visited models in result.definitions
Hmm, do you mean storing the schema of the current model in the definitions?
Like
{
title: 'Category',
properties: {
name: {
type: 'string'
},
},
definitions: {
Category: {
title: 'Category',
properties: {
name: {
type: 'string'
},
},
},
},
}
There was a problem hiding this comment.
Hmm, do you mean storing the schema of the current model in the
definitions?
No, only schema of models used by properties (i.e. Product when emitting schema for Category).
6bedae4 to
fdd1cc4
Compare
|
@bajtos @raymondfeng Thanks for the review, feedback applied. @bajtos My implementation is still slightly different than your PoC, see my comment I may need some time to explore you original approach then compare. |
Sure, take your time. My spike code is just a prototype, it's fine to end up with a different implementation. As long as it supports the use cases we need to support. |
ada0046 to
80ab41d
Compare
|
@bajtos I figured out the difference. Your PoC skipped prompting visited definition to the top level with this line of code, which simplifies everything! I still didn't get the idea of tracking the visited model with its generated schema like |
bajtos
left a comment
There was a problem hiding this comment.
The implementation looks good at high level, let's improve few implementation details now.
| result.title = meta.title || ctor.name; | ||
| const title = meta.title || ctor.name; | ||
|
|
||
| if (options.visited.has(title)) return {}; |
There was a problem hiding this comment.
IIUC, by returning an empty object, you are trying to satisfy the return value type JSONSchema while returning an invalid (or at least useless) value. That's effectively bypassing type checks :(
I think this was the reason why I was keeping the visited schemas in the cache?
Anyhow. Please change this line to return undefined to make it clear that when a model has been already visited, then we are not returning it's schema.
To preserve backwards compatibility, I am proposing the following signature:
export function modelToJsonSchema(ctor: Function): JSONSchema;
export function modelToJsonSchema(
ctor: Function,
options: JsonSchemaOptions = {},
): JSONSchema | undefined;
export function modelToJsonSchema(
ctor: Function,
options: JsonSchemaOptions = {},
): JSONSchema | undefined {
// the implementation
}Alternatively, and I think this is a better option when it comes to type safety, promote visited to a top-level argument and keep the JsonSchemaOptions as an empty interface for now.
export function modelToJsonSchema(
ctor: Function,
options: JsonSchemaOptions = {}
): JSONSchema;
export function modelToJsonSchema(
ctor: Function,
options: JsonSchemaOptions,
visited: Set<string>;
): JSONSchema | undefined;
export function modelToJsonSchema(
ctor: Function,
options: JsonSchemaOptions = {},
visited?: Set<string>;
): JSONSchema | undefined {
// the implementation
}There was a problem hiding this comment.
That's effectively bypassing type checks :(
I think this was the reason why I was keeping the visited schemas in the cache?
Oh...that's true!
by returning an empty object, you are trying to satisfy the return value type JSONSchema while returning an invalid (or at least useless) value.
I agree.
And undefined and {} both return useless value which logically isn't as perfect as returning the cache value.
And since the cached schema is just a reference, tracking with a Set won't save space compared with tracking with cache. I switched to the approach in your PoC.
| ctor: Function, | ||
| options: JsonSchemaOptions = {}, | ||
| ): JSONSchema { | ||
| options.visited = options.visited || new Set<string>(); |
There was a problem hiding this comment.
Please don't modify original options.
| options.visited = options.visited || new Set<string>(); | |
| if (!options.visited) options = {...options, visited: new Set<string>()}; |
Please add a test to verify.
There was a problem hiding this comment.
Please don't modify original options.
Hmm, any reason?
There was a problem hiding this comment.
It's confusing for callers when you modify the options object. The caller may use the same options instance for multiple calls.
For example:
const SCHEMA_OPTIONS = {includeRelations: true};
const productSchema = getJsonSchema(Product, SCHEMA_OPTIONS);
const categorySchema = getJsonSchema(Category, SCHEMA_OPTIONS);There was a problem hiding this comment.
👍 got it thank you.
| // error if itemType/type is not a string or a function | ||
| resolveType(metaProperty.itemType as string | Function) | ||
| // error if itemType/type is not a string or a function | ||
| resolveType(metaProperty.itemType as string | Function) |
There was a problem hiding this comment.
This looks like an unrelated whitespace change to me, PTAL
| ctor: Function, | ||
| options: JsonSchemaOptions = {}, | ||
| ): JSONSchema { | ||
| options.visited = options.visited || {}; |
There was a problem hiding this comment.
Please don't modify original options, see the discussion above.
There was a problem hiding this comment.
applied and new test case added.
d007850 to
20bb8a2
Compare
| }, | ||
| }); | ||
| }); | ||
| it('does not pollute the JSON schema options', () => { |
There was a problem hiding this comment.
I wonder if we can treat this as a unit test instead. Feel free to ignore.
nabdelgadir
left a comment
There was a problem hiding this comment.
Just have one minor question, but LGTM 👍
| } | ||
|
|
||
| const JSON_SCHEMA_OPTIONS = {}; | ||
| // tslint:disable-next-line:no-unused |
There was a problem hiding this comment.
Question, why do you need the const categorySchema = part instead of just calling getJsonSchema(Category, JSON_SCHEMA_OPTIONS);?
|
@jannyHou Do we have a test to make sure such json schemas are converted to OpenAPI spec correctly? For example, using: components
- schemas
- Product |
|
@raymondfeng Yeah I believe we have it in the |
20bb8a2 to
acf6da6
Compare
Fixes #2628
Description
The solution here is a little bit different than the proposal in the original issue:
Thedefinitionscontains bothCategoryandProduct.(This is not true) So thatrefers to a valid definition entry.visitedfield isan arraya set tracks the title of visited items instead of caching the schema. This is becausegetJsonSchemais called during each iteration of the property schema generation, not after the completed model schema(without definitions) get built.Checklist
👉 Read and sign the CLA (Contributor License Agreement) 👈
npm testpasses on your machineAffected artifact templates inpackages/cliwere updatedAffected example projects inexamples/*were updated👉 Check out how to submit a PR 👈