Skip to content
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(serializer): add inferClass option #861

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17,206 changes: 12,841 additions & 4,365 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion packages/concerto-core/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -343,9 +343,11 @@ class SecurityException extends BaseException {
class Serializer {
+ void constructor(Factory,ModelManager,object?)
+ void setDefaultOptions(Object)
+ Object toJSON(Resource,Object?,boolean?,boolean?,boolean?,boolean?,boolean?,number?) throws Error
+ Object toJSON(Resource,Object?,boolean?,boolean?,boolean?,boolean?,boolean?,number?,boolean?) throws Error
+ Resource fromJSON(Object,Object?,boolean,boolean,number?,boolean?)
}
+ string qualifyTypeName() throws Error
+ string resolveFullyQualifiedTypeName()
class TypeNotFoundException extends BaseException {
+ void constructor(string,string|,string,string)
+ string getTypeName()
Expand Down
2 changes: 2 additions & 0 deletions packages/concerto-core/changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
# Note that the latest public API is documented using JSDocs and is available in api.txt.
#

Version 3.16.8 {7b282538e0319c3872928e133560441e} 2024-06-14
- Added `inferClass` option to Serializer.toJSON

Version 3.16.7 {8f455df1e788c4994f423d6e236bee21} 2024-05-01
- Added missing `strictQualifiedDateTimes` option to Serializer.fromJSON
Expand Down
3 changes: 1 addition & 2 deletions packages/concerto-core/lib/basemodelmanager.js
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@
if (this.isStrict()) {
throw new MetamodelException(err.message);
} else {
console.warn('Invalid metamodel found. This will throw an exception in a future release. ', err.message);

Check warning on line 262 in packages/concerto-core/lib/basemodelmanager.js

View workflow job for this annotation

GitHub Actions / Unit Tests (18.x, macOS-latest)

Unexpected console statement
}
}

Expand Down Expand Up @@ -592,9 +592,7 @@
* @throws {TypeNotFoundException} - if the type cannot be found or is a primitive type.
*/
getType(qualifiedName) {

const namespace = ModelUtil.getNamespace(qualifiedName);

const modelFile = this.getModelFile(namespace);
if (!modelFile) {
const formatter = Globalize.messageFormatter('modelmanager-gettype-noregisteredns');
Expand All @@ -604,6 +602,7 @@
}

const classDecl = modelFile.getType(qualifiedName);

if (!classDecl) {
const formatter = Globalize.messageFormatter('modelmanager-gettype-notypeinns');
throw new TypeNotFoundException(qualifiedName, formatter({
Expand Down
5 changes: 5 additions & 0 deletions packages/concerto-core/lib/serializer.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const { utcOffset: defaultUtcOffset } = DateTimeUtil.setCurrentTime();
const baseDefaultOptions = {
validate: true,
utcOffset: defaultUtcOffset,
inferClass: false
};

// Types needed for TypeScript generation.
Expand Down Expand Up @@ -92,6 +93,8 @@ class Serializer {
* @param {boolean} [options.convertResourcesToId] - Convert resources that
* are specified for relationship fields into their id, false by default.
* @param {number} [options.utcOffset] - UTC Offset for DateTime values.
* @param {boolean} [options.inferClass] - Only create $class in JSON when it
* cannot be inferred from the model, false by default
* @return {Object} - The Javascript Object that represents the resource
* @throws {Error} - throws an exception if resource is not an instance of
* Resource or fails validation.
Expand Down Expand Up @@ -123,10 +126,12 @@ class Serializer {
options.convertResourcesToId === true,
false,
options.utcOffset,
options.inferClass === true
);

parameters.stack.clear();
parameters.stack.push(resource);
parameters.isRoot = true;

// this performs the conversion of the resouce into a standard JSON object
let result = classDeclaration.accept(generator, parameters);
Expand Down
31 changes: 30 additions & 1 deletion packages/concerto-core/lib/serializer/jsongenerator.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,15 @@ class JSONGenerator {
* @param {boolean} [ergo] - Deprecated - This is a dummy parameter to avoid breaking any consumers. It will be removed in a future release.
* are specified for relationship fields into their id, false by default.
* @param {number} [utcOffset] UTC Offset for DateTime values.
* @param {boolean} [inferClass] Only include $class in JSON when it cannot be inferred from model
*/
constructor(convertResourcesToRelationships, permitResourcesForRelationships, deduplicateResources, convertResourcesToId, ergo, utcOffset) {
constructor(convertResourcesToRelationships, permitResourcesForRelationships, deduplicateResources, convertResourcesToId, ergo, utcOffset, inferClass) {
this.convertResourcesToRelationships = convertResourcesToRelationships;
this.permitResourcesForRelationships = permitResourcesForRelationships;
this.deduplicateResources = deduplicateResources;
this.convertResourcesToId = convertResourcesToId;
this.utcOffset = utcOffset || 0;
this.inferClass = inferClass;
}

/**
Expand Down Expand Up @@ -142,18 +144,40 @@ class JSONGenerator {
}
}

// by default we set the $class on the result
result.$class = classDeclaration.getFullyQualifiedName();

// if we are in the inferClass mode and this is not the root instance
// we attempt to remove the $class
if(this.inferClass && !parameters.isRoot && parameters.field) {
const fieldClassDecl = parameters.modelManager.getType(parameters.field.getFullyQualifiedTypeName());
// if the $class cannot be unambigiously inferred from the type of the field in the model
// we have to include it, but we attempt to shorten it, if the object and the field are in the same ns
if(fieldClassDecl.getAssignableClassDeclarations().length > 2) {
dselman marked this conversation as resolved.
Show resolved Hide resolved
dselman marked this conversation as resolved.
Show resolved Hide resolved
const objAndFieldSameNs = parameters.field ? ModelUtil.getNamespace(parameters.field.getFullyQualifiedTypeName()) === obj.getNamespace() : false;
result.$class = (objAndFieldSameNs && !parameters.isRoot) ? obj.getType() : obj.getFullyQualifiedType();
}
else {
// we don't need the $class - we can recreate it from the model
delete result.$class;
}
}

if(this.deduplicateResources && id) {
result.$id = id;
}

// no longer dealing with the root object...
parameters.isRoot = false;

// Walk each property of the class declaration
const properties = classDeclaration.getProperties();
for (let index in properties) {
const property = properties[index];
const value = obj[property.getName()];
if (!Util.isNull(value)) {
parameters.stack.push(value);
parameters.field = property; // save the property to the stack!
dselman marked this conversation as resolved.
Show resolved Hide resolved
result[property.getName()] = property.accept(this, parameters);
}
}
Expand All @@ -178,6 +202,7 @@ class JSONGenerator {
const item = obj[index];
if (!field.isPrimitive() && !ModelUtil.isEnum(field)) {
parameters.stack.push(item, Typed);
parameters.field = field; // save the field to the stack!
const classDeclaration = parameters.modelManager.getType(item.getFullyQualifiedType());
array.push(classDeclaration.accept(this, parameters));
} else {
Expand All @@ -192,11 +217,13 @@ class JSONGenerator {
} else if (ModelUtil.isMap(field)) {
parameters.stack.push(obj);
const mapDeclaration = parameters.modelManager.getType(field.getFullyQualifiedTypeName());
parameters.field = field; // save the field to the stack!
result = mapDeclaration.accept(this, parameters);
}
else {
parameters.stack.push(obj);
const classDeclaration = parameters.modelManager.getType(obj.getFullyQualifiedType());
parameters.field = field; // save the field to the stack!
result = classDeclaration.accept(this, parameters);
}

Expand Down Expand Up @@ -256,6 +283,7 @@ class JSONGenerator {
parameters.seenResources.add(fqi);
parameters.stack.push(item, Resource);
const classDecl = parameters.modelManager.getType(relationshipDeclaration.getFullyQualifiedTypeName());
parameters.field = relationshipDeclaration; // save the relationship to the stack!
array.push(classDecl.accept(this, parameters));
parameters.seenResources.delete(fqi);
}
Expand All @@ -274,6 +302,7 @@ class JSONGenerator {
parameters.seenResources.add(fqi);
parameters.stack.push(obj, Resource);
const classDecl = parameters.modelManager.getType(relationshipDeclaration.getFullyQualifiedTypeName());
parameters.field = relationshipDeclaration; // save the relationship to the stack!
result = classDecl.accept(this, parameters);
parameters.seenResources.delete(fqi);
}
Expand Down
68 changes: 49 additions & 19 deletions packages/concerto-core/lib/serializer/jsonpopulator.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,51 @@
}
}

/**
* Resolves the fully-qualified model name for a JSON object.
* @param {string} clazz the type name (FQN or short)
* @param {*} field a Field (which could be a Relationship)
* @returns {string} the fully qualified name of the object
* @throws {Error} if a short type name has not been imported
*/
function qualifyTypeName(clazz, field) {
const ns = ModelUtil.getNamespace(clazz);
if(ns.length > 0) {
return clazz; // already FQN
}
else {
// a short name, we use the namespace from the type of the field
const fqn = field.getFullyQualifiedTypeName();
return ModelUtil.getFullyQualifiedName(ModelUtil.getNamespace(fqn), clazz);
}
}

/**
* Resolves the fully-qualified model name for a JSON object.
* @param {*} obj an object with an optional $class
* @param {*} field a Field (which could be a Relationship)
* @returns {string} the fully qualified name of the object, based on its explicit $class
* or a $class inferred from the model
*/
function resolveFullyQualifiedTypeName(obj, field) {
if(obj.$class) {
return qualifyTypeName(obj.$class, field);
}
else {
const fqn = field.getFullyQualifiedTypeName();
const mm = field.getParent().getModelFile().getModelManager();
const classDecl = mm.getType(fqn);
const assignable = classDecl.isClassDeclaration?.() ? classDecl.getAssignableClassDeclarations()
.filter( a => !a.isAbstract()) : [classDecl];
if(assignable.length !== 1) {
throw new Error(`The type ${fqn} which was unambigious is now ambigious due to ${assignable.map(a => a.getFullyQualifiedName()).join(',')}`);
}
dselman marked this conversation as resolved.
Show resolved Hide resolved
else {
return assignable[0].getFullyQualifiedName();
}
}
}

/**
* Populates a Resource with data from a JSON object graph. The JSON objects
* should be the result of calling Serializer.toJSON and then JSON.parse.
Expand Down Expand Up @@ -105,7 +150,7 @@
this.strictQualifiedDateTimes = strictQualifiedDateTimes;

if (process.env.TZ){
console.warn(`Environment variable 'TZ' is set to '${process.env.TZ}', this can cause unexpected behaviour when using unqualified date time formats.`);

Check warning on line 153 in packages/concerto-core/lib/serializer/jsonpopulator.js

View workflow job for this annotation

GitHub Actions / Unit Tests (18.x, macOS-latest)

Unexpected console statement
}
}

Expand Down Expand Up @@ -266,13 +311,7 @@
let result = null;

if(!field.isPrimitive?.() && !field.isTypeEnum?.()) {
let typeName = jsonItem.$class;
if(!typeName) {
// If the type name is not specified in the data, then use the
// type name from the model. This will only happen in the case of
// a sub resource inside another resource.
typeName = field.getFullyQualifiedTypeName();
}
const typeName = resolveFullyQualifiedTypeName(jsonItem, field);

// This throws if the type does not exist.
const declaration = parameters.modelManager.getType(typeName);
Expand Down Expand Up @@ -410,13 +449,8 @@
if (!this.acceptResourcesForRelationships) {
throw new Error('Invalid JSON data. Found a value that is not a string: ' + jsonObj + ' for relationship ' + relationshipDeclaration);
}

// this isn't a relationship, but it might be an object!
if(!jsonItem.$class) {
throw new Error('Invalid JSON data. Does not contain a $class type identifier: ' + jsonItem + ' for relationship ' + relationshipDeclaration );
}

const classDeclaration = parameters.modelManager.getType(jsonItem.$class);
const typeName = resolveFullyQualifiedTypeName(jsonItem, relationshipDeclaration);
const classDeclaration = parameters.modelManager.getType(typeName);

// create a new instance, using the identifier field name as the ID.
let subResource = parameters.factory.newResource(classDeclaration.getNamespace(),
Expand All @@ -436,11 +470,7 @@
throw new Error('Invalid JSON data. Found a value that is not a string: ' + jsonObj + ' for relationship ' + relationshipDeclaration);
}

// this isn't a relationship, but it might be an object!
if(!jsonObj.$class) {
throw new Error('Invalid JSON data. Does not contain a $class type identifier: ' + jsonObj + ' for relationship ' + relationshipDeclaration );
}
const classDeclaration = parameters.modelManager.getType(jsonObj.$class);
const classDeclaration = parameters.modelManager.getType(resolveFullyQualifiedTypeName(jsonObj, relationshipDeclaration));

// create a new instance, using the identifier field name as the ID.
let subResource = parameters.factory.newResource(classDeclaration.getNamespace(),
Expand Down
Loading
Loading