Skip to content

moved from types to classes #114

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

Merged

Conversation

antmendoza
Copy link
Contributor

@antmendoza antmendoza commented Jun 14, 2021

Many thanks for submitting your Pull Request ❤️!

What this PR does / why we need it:
closes #111
closes #104

Special notes for reviewers:

There are many files changes but we can resume them in:

I am still working in the generate-classes file to have most of the code autogenerated, will create a PR soon.
Additional information (if needed):

Signed-off-by: Antonio Mendoza Pérez <[email protected]>
@antmendoza antmendoza requested a review from tsurdilo as a code owner June 14, 2021 20:55
@antmendoza antmendoza requested a review from JBBianchi June 14, 2021 21:02
@antmendoza
Copy link
Contributor Author

antmendoza commented Jun 14, 2021

format validation error output

this is not related with the rest of the changes, I can create another PR for this if you want

Copy link
Member

@JBBianchi JBBianchi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Impressive work @antmendoza, congratz! There are still few things to address, the "bigger" one being the "hydrating" of the non primitive types in the constructors.

Comment on lines 29 to 33
const result = {} as Specification.Action;

Object.assign(result, data);
validate('Action', result);
return result;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the building functions should rely on the constructor of their underlying type.

For instance:

const model = new Specification.Action(data); // I don't like `result`, it's not "self describing", but that's just a little detail, not very important
validate('Action', model );
return model;

This keeps the creation of the object and the defaulting of the properties DRY. If you take the example of callbackstate-builder, it does the same think than it's constructor counterpart. Furthermore, using a constructor instead of an object literal allows the use of instanceof (aka runtime typechecking in addition to compile time typechecking):

const foo = new Specification.Action(); // This means "create a new instance of Action"
const bar = {} as Specification.Action; // This means to TypeScript only "consider this object literal as Action"
console.log(foo instanceof Specification.Action); // true, the object is "typed" at runtime
console.log(bar instanceof Specification.Action); // false, this is just an object literal

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks @JBBianchi

I can change the property name to model, no problem

The problem using constructor is that the code in the actual constructor is setting values we don't want in the final serialized json for example https://github.com/antmendoza/sdk-typescript/blob/from-types-to-classes/src/lib/definitions/function.ts#L18, we don't want type:rest if the client has not set it. Instead we want the value on deserialization.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, and I don't really see how to prevent that... Maybe with a prepareSerialization() method or something that would need to be called on the whole object tree to mutate the serialized copy but that doesn't sound right... Or maybe using class-transformer for that? But that feels wrong as well :/

Comment on lines 29 to 37
const result = {
type: 'event',
} as Specification.Eventstate;

if (!data.end && !data.transition) {
result.end = true;
}

Object.assign(result, data);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In relation to my previous comment, this should be done in the constructor and be kept in one place only.

Comment on lines 29 to 35
const result = {
type: 'delay',
} as Specification.Delaystate;

Object.assign(result, data);
validate('Delaystate', result);
return result;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handling of usedForCompensation is missing?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since usedForCompensation is required now I don't think we have to set the value, I am thinking now that setting the value useForCompensation in the constructor is not needed, I will have a look

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might not be useful indeed, it should validate/be considerated false by default without having to specify it. @tsurdilo do you agree?

@@ -19,7 +19,7 @@ To build the project and run tests locally:
```sh
git clone https://github.com/serverlessworkflow/sdk-typescript.git
cd sdk-typescript
npm install && npm run update-code-base && npm run test
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With or without running the code generation should produce the same output...


export class Action {
constructor(model: any) {
const result = {};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use the name defaultModel instead of result ? It's more descriptive?


export class Subflowstate {
constructor(model: any) {
const result = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing type: 'subflow' (after Object.assign to enforce the const event if something else is specified in model ?)

/**
* State type
*/
type?: 'subflow';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

type is required

Comment on lines 108 to 111
const functions = this.functions;
if (typeof functions === typeof []) {
this.functions = (functions as Function[]).map((f) => new Function(JSON.stringify(f))) as Functions;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand the copy of the reference of function neither the stringify in the Function constructor.

I thing I would go for:

if (typeof model.functions !== typeof '') {
  this.functions = (model.functions as Function[]).map((f) => new Function(f)) as Functions;
}

but maybe I'm missing something?

Furthermore, I think this should ideally be done for all properties that are not primitive types, in all the definitions... That's the only way to properly hydrate the models I think.

[key: string]: any;
};

const states = this.states;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same, I don't think copying the reference really makes sense.

metadata?: /* Metadata information */ Metadata;
static fromSource(value: string): Specification.Workflow {
try {
return yaml.load(value) as Specification.Workflow;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be wrapped into the constructor:

return new Specification.Workflow(yaml.load(value));

@antmendoza
Copy link
Contributor Author

After reading all your comments @JBBianchi most of then are related to setting default values and constants and I think that everything can be merged in the constructor, as you've mentioned

👍

@antmendoza
Copy link
Contributor Author

antmendoza commented Jun 16, 2021

By using the constructor in the builders makes the test fail, since serialized workflow (json file) do not has set default values like type:rest for Function class.

see antmendoza@86d4583#diff-c590f689b760b021149371854a8e8dcf45b515f719c7a8abfd5dafea608cf7f2R136

@JBBianchi
Copy link
Member

By using the constructor in the builders makes the test fail, since serialized workflow (json file) do not has set default values like type:rest for Function class.

see antmendoza@86d4583#diff-c590f689b760b021149371854a8e8dcf45b515f719c7a8abfd5dafea608cf7f2R136

If you use constructors all the way down, even when deserializing, it should be ok, shouldn't it?

@antmendoza
Copy link
Contributor Author

deserializing is working ok, serializing is when we are populating default values we don't want to be in the final serialized string

as you mention before, we have to modify the object before serialize it, the place I can see now to do it is in the xxxBuildingFn event if I don't like it.


function functionBuildingFn(data: Specification.Function): () => Specification.Function {
  return () => {
    
    const model = new Specification.Function(data);
    
    //we want the value only if the client has set it.
    model.type = data.type;
    
    validate('Function', model);
    return model;
  };
}

having this logic here is weird to me, anyway.

(I am thinking that the proper way might be having two serilize/deserialize methods or classes per type, as they have specific logic )

Let's make this works and we will see later, as the API should not change

I am going to modify the code in this way and see if everything works as expected

@JBBianchi
Copy link
Member

Ah yes, sorry, I mixed things up. Serializing is indeed a bit tricky (maybe even more than "a bit")...
The objects could maybe have a makeSerializable() method that would recursively delete default values:

export class Workflow {

  makeSerializable() {
    const clone = deepCopy(this);
    // managing self defaults
    if (clone.expressionLang === 'jq') {
      delete clone.expressionLang;
    }
    // deeply removing defaults
    if (typeof clone.start !== typeof '') {
      clone.start = clone.start.makeSerializable();
    }
    if (clone.execTimeout) {
      clone.execTimeout = clone.execTimeout.makeSerializable();
    }
    //...
    clone.states = clone.states.map(s => s.makeSerializable());
    return clone;
  }

  static toJson(workflow: Workflow): string {
    validate('Workflow', workflow);
    return JSON.stringify(workflow.makeSerializable());
  }

  static toYaml(workflow: Workflow): string {
    validate('Workflow', workflow);
    return yaml.dump(workflow.makeSerializable());
  }

}

@antmendoza
Copy link
Contributor Author

I don't think we should delete always default values, only if the user/client has not set them

@JBBianchi
Copy link
Member

How to differentiate the two use cases?

@antmendoza
Copy link
Contributor Author

We can't, to make it work I am re-setting the value after creating the object, in the builder. Pretty ugly, I know.

function functionBuildingFn(data: Specification.Function): () => Specification.Function {
  return () => {
    const model = new Specification.Function(data);

    //HERE
    model.type = data.type;

    validate('Function', model);
    return model;
  };
}


I am testing also the implementation by "hidratating" non-primitive object and I think that it is not enough, since non-primitive properties are created as Object type

    const data = {
      "events": [
        {
          "name": "CarBidEvent",
          "type": "carBidMadeType",
          "source": "carBidEventSource"
        }
      ]
    }
    
    const model = new Workflow(data);
   // @ts-ignore
   expect(model.events[0]!.constructor.name).toBe("Eventdef"); -> // Expected 'Object' to be 'Eventdef'.

so for each non primitive property I am reseting it by instantiating the corresponding class

@JBBianchi
Copy link
Member

We can't, to make it work I am re-setting the value after creating the object, in the builder. Pretty ugly, I know.

function functionBuildingFn(data: Specification.Function): () => Specification.Function {
  return () => {
    const model = new Specification.Function(data);

    //HERE
    model.type = data.type;

    validate('Function', model);
    return model;
  };
}

I don't understand why you'd set type again here, model.type is set inside the constructor ... Am I missing something?

I am testing also the implementation by "hidratating" non-primitive object and I think that it is not enough, since non-primitive properties are created as Object type

    const data = {
      "events": [
        {
          "name": "CarBidEvent",
          "type": "carBidMadeType",
          "source": "carBidEventSource"
        }
      ]
    }
    
    const model = new Workflow(data);
   // @ts-ignore
   expect(model.events[0]!.constructor.name).toBe("Eventdef"); -> // Expected 'Object' to be 'Eventdef'.

so for each non primitive property I am reseting it by instantiating the corresponding class

In the current state of workflow.ts, I don't see the hydration of events. If the non primitive properties are not instanciated, they indeed are just plain objects. The constructor is missing

if (model.events && typeof model.events !== typeof '') {
  this.events = model.events.map(e => new Eventdef(e));
}

Another concern I just thought about: what if the user sets a property outside the constructor... ?

const wf = new Workflow();
wf.events = [
  {
    name: "CarBidEvent",
    type: "carBidMadeType",
    source: "carBidEventSource"
  }
];

This would assign a plain object to the events property, it would not be hydrated ... :/

The workaround would be to define the properties with getters/setters to hydrate objects instead of doing it in the constructor ...

export class Workflow {
  private _events?: Events;
  get events(): Events {
    return this._events;
  }
  set events(value: Events) {
    if (value && typeof value !== typeof '') {
      this.events = value.map(e => new Eventdef(e));
    }
    else {
      this.events = value;
    }
  }

  constructor(model: any) {
    const defaultModel = { expressionLang: 'jq' } as Specification.Workflow;
    Object.assign(this, defaultModel, model);
  }
}

but that's a lot of hassle...

Signed-off-by: Antonio Mendoza Pérez <[email protected]>
Signed-off-by: Antonio Mendoza Pérez <[email protected]>
@antmendoza
Copy link
Contributor Author

antmendoza commented Jun 21, 2021

I don't understand why you'd set type again here, model.type is set inside the constructor ...

I have added a test @JBBianchi

hope this helps

@JBBianchi
Copy link
Member

I don't understand why you'd set type again here, model.type is set inside the constructor ...

I have added a test @JBBianchi

hope this helps

Sorry, it's Monday morning, maybe I'm not awake but I still don't understand. "In memory" objects should have the default value...

This line of function-builder is redundant:

model.type = data.type;

It's already assigned when calling:

const model = new Specification.Function(data);

As the function constructor already assign the values:

Object.assign(this, defaultModel, model);

In other words:

const fn1 = new Function({ name: 'function', operation: 'operation' }); // fn1.type === 'rest'
const fn2 = functionBuilder().name('function').operation('operation').build(); // fn2.type === 'rest'
const fn3 = new Function({ name: 'function', operation: 'operation', type: 'grpc' }); // fn3.type === 'grpc'
const fn4 = functionBuilder().name('function').operation('operation').type('grpc').build(); // fn4.type === 'grpc'
const fn5 = new Function({ name: 'function', operation: 'operation', type: 'rest' }); // fn5.type === 'rest'
const fn6 = functionBuilder().name('function').operation('operation').type('rest').build(); // fn6.type === 'rest'
fn1.makeSerializable(); // { name: "function", operation: "operation" }
fn2.makeSerializable(); // { name: "function", operation: "operation" }
fn3.makeSerializable(); // { name: "function", operation: "operation", type: 'gprc' }
fn4.makeSerializable(); // { name: "function", operation: "operation", type: 'gprc' }
fn5.makeSerializable(); // { name: "function", operation: "operation" } <-- default is gone even if the user specified it
fn6.makeSerializable(); // { name: "function", operation: "operation" } <-- default is gone even if the user specified it

Whenever the user set the default value or not, the value exists in memory but shouldn't get outputted in the serialized object I think. It's "not possible" to flag whenever the user actually set the property and still output the value in the serialized object if he did. At least, I don't think it's necessary/makes sense.

@antmendoza
Copy link
Contributor Author

It is Monday for everyone @JBBianchi

I am not sure but I think that if the user set the value, the value should be outputted ,at least that would be the behaviour I was expecting as a client using the library.

As far I have seen, this only is happening with workflow.expressionLang and function.type

Signed-off-by: Antonio Mendoza Pérez <[email protected]>
@JBBianchi
Copy link
Member

I think usedForCompensation might also be concerned, eventstate.exclusive, operationstate.actionmode, parallelstate.completionType, subflowstate.waitForCompletion, workflow.end / workflow.end.compensate, repeat.continueOnError, workflow.keepActive, exectimeout.interrupt, transition.compensate, onevents.actionMode ...

@antmendoza
Copy link
Contributor Author

👍 Thank you @JBBianchi , I am going to have a look

@antmendoza
Copy link
Contributor Author

@JBBianchi I have added a couple of test for subflowstateBuilder and eventstateBuilder

615e174

how do you think this should work ?

Copy link
Member

@JBBianchi JBBianchi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JBBianchi I have added a couple of test for subflowstateBuilder and eventstateBuilder

615e174

how do you think this should work ?

I think the property assertion "toBeUndefined" are not valid, they should reflect the default value from what I understood from @tsurdilo guidelines.

Then, following my previous train of thoughts, the lines expect(JSON.stringify(object)).toBe( should be expect(JSON.stringify(object.makeSerializable())).toBe(

import { eventstateBuilder } from '../../../src/lib/builders/eventstate-builder';
import { oneventsBuilder } from '../../../src/lib/builders/onevents-builder';

describe('subflowstateBuilder ', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

eventstate builder

.transition('Get Book Status')
.build();

expect(object.exclusive).toBeUndefined();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it('should build an object', () => {
const object = subflowstateBuilder().name('StartApplication').workflowId('startApplicationWorkflowId').build();

expect(object.waitForCompletion).toBeUndefined();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@antmendoza
Copy link
Contributor Author

antmendoza commented Jun 22, 2021

Ok, sorry I miss all the defaults when get rid of 'class-transformer'

so putting this on code, the behaviour should be something like



describe('eventstateBuilder ', () => {
  it('should build an object', () => {
    
    const object = eventstateBuilder()
      .name('Book Lending Request')
      .onEvents([oneventsBuilder().eventRefs(['Book Lending Request Event']).build()])
      .transition('Get Book Status')
      .build();

    expect(object.exclusive).toBeTrue();
  
    const serializedObject = object.serialize();
    expect(serializedObject).toBe(
      JSON.stringify({
        type: 'event',
        name: 'Book Lending Request',
        onEvents: [
          {
            eventRefs: ['Book Lending Request Event'],
          },
        ],
        transition: 'Get Book Status',
      })
    );
    
    
    const deserializedObject = object.deserialize(serializedObject)
    expect(deserializedObject.exclusive).toBeTrue();
    
    
    
    //Having serialize/ deserialize de following signature
  
    //  deserialize(value: string): Eventstate {

    //   }
    //   serialize(): string {

    //   }
    //  
  
  });
});




@JBBianchi
Copy link
Member

I think that's the idea. I don't know if you need a serialize() method on each object but at least a method to get rid of the defaults (in my example, makeSerializable() (which is an ugly name) returns an object. It is required to traverse nested objects to rebuild nested objects without defaults)

@antmendoza
Copy link
Contributor Author

I think we should move serialize/deserialize functions to another class, workflowSerializer, functionSerializer... , otherwise the compiler is complaining all the time because objects does not have serialize/deserialize properties, which makes sense.

Making them optionals does not seem the solution.

BTW I am not sure that deserialize is needed

@JBBianchi
Copy link
Member

otherwise the compiler is complaining all the time because objects does not have serialize/deserialize properties, which makes sense.

I'm not sure to understand what you mean by "the compiler is complaining all the time" but I'm not against having dedicated classes for those.

Note that serialize/deserialize should rather be toJson/toYaml/fromString (or maybe serialize(obj, SerializationFormat.Json) for instance) I think, like we have in the current workflow-converter.

Signed-off-by: Antonio Mendoza Pérez <[email protected]>
@antmendoza
Copy link
Contributor Author

Added default values to each constructor and normalize method when required, to delete recursively properties whose value = default value

Signed-off-by: Antonio Mendoza Pérez <[email protected]>
@antmendoza
Copy link
Contributor Author

antmendoza commented Jun 28, 2021

@tsurdilo @JBBianchi this PR is ready to be reviewed.

@JBBianchi since we are creating object for each non-primitive property in the constructor , do you thing that hydration is still needed?

@JBBianchi
Copy link
Member

You did an amazing job @antmendoza, congratz! 👏

@tsurdilo @JBBianchi this PR is ready to be reviewed.

I'll try to digg into the details and review asap. I did a quick test and a very fast look around, it seems great. Maybe a detail or two that I'll point but it will probably be more about code style/preference rather than concepts.

@JBBianchi since we are creating object for each non-primitive property in the constructor , do you thing that hydration is still needed?

I don't understand the question, creating a new instance for each non promivite prop is hydration. Instead of having "dumb" objects, you recursively hydrated each object to its instance. So if I'm not missing something, it's all good here !

Thanks again mate, you did great!

@antmendoza
Copy link
Contributor Author

antmendoza commented Jul 2, 2021

Hi @JBBianchi @tsurdilo

did you have the chance to look into the PR...?

this is not a final code, there will be more changes in the sdk for sure... If there is no any major issue I would like to have this merge at some point soon in order to cover the 0.6 spec version

If you need any clarification please let me know 🙂

@JBBianchi
Copy link
Member

I'm very sorry I didn't have the opportunity to check in depth. At a first glance, it looks good so let's merge, we can still change things if needed.

@antmendoza antmendoza merged commit 6e6dc9d into serverlessworkflow:main Jul 4, 2021
@antmendoza antmendoza deleted the from-types-to-classes branch July 16, 2021 14:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Handle default values
3 participants