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/refactor: dedicated classes for texts and inline comps #356

Merged
merged 33 commits into from
Jan 9, 2025

Conversation

stockbal
Copy link
Contributor

@stockbal stockbal commented Oct 12, 2024

Hi @daogrady,

proposal to get rid of one of the CSN flavors. I chose to remove the xtended flavor. All in all the required fixes/changes were not many and had some nice side effects.

  • working dedicated classes for inline compositions
  • working dedicated classes for localized elements

Tasks

  • add test cases

Here is short sample to show the changes to the generated classes (only the important parts are included)

Sample cds schema

namespace bookshop;

entity Books : cuid {
  title      : localized String;
  publishers : Composition of many {
                 key ID      : UUID;
                     name    : String;
                     type    : String enum {
                       self;
                       independent;
                     };
                     offices : Composition of many {
                                 key ID      : UUID;
                                     city    : String;
                                     zipCode : String;
                               }
               }
}

Generated index.ts for namespace bookshop

export function _BookAspect<TBase extends new (...args: any[]) => object>(Base: TBase) {
  return class Book extends _._cuidAspect(Base) {
    declare title?: string | null;
    declare publishers?: __.Composition.of.many<Books.publishers>;
    // properties to access composition and association to generated table for 'localized' elements
    // #################################
    declare texts?: __.Composition.of.many<Books.texts>;
    declare localized?: __.Association.to<Books.text> | null;
    // #################################
  };
}
export class Book extends _BookAspect(__.Entity) {}
export class Books extends Array<Book> {$count?: number}

export namespace Books {
  // enum
  const publisher_type = {
    self: "self",
    independent: "independent",
  } as const;
  type publisher_type = "self" | "independent"

  // classes for inline composition inside namespace of parent entity
  export function _publisherAspect<TBase extends new (...args: any[]) => object>(Base: TBase) {
    return class publisher extends Base {
      declare up_?: __.Key<__.Association.to<Book>>;
      declare up__ID?: __.Key<string>;
      declare ID?: __.Key<string>;
      declare name?: string | null;
      declare type?: publisher_type | null;
      declare offices?: __.Composition.of.many<Books.publishers.offices>;
      
      static type = publisher_type
    };
  }
  export class publisher extends _publisherAspect(__.Entity) {}
  export class publishers extends Array<publisher> {$count?: number}
  
  // generated text entity inside namespace of parent entity
  export function _textAspect<TBase extends new (...args: any[]) => object>(Base: TBase) {
    return class text extends _sap_common._TextsAspectAspect(Base) {
      declare ID?: __.Key<string>;
      declare title?: string | null;
    };
  }
  export class text extends _textAspect(__.Entity) {}
  export class texts extends Array<text> {$count?: number}
  
}

export namespace Books.publishers {
  // classes for nested inline composition 'offices'
  export function _officeAspect<TBase extends new (...args: any[]) => object>(Base: TBase) {
    return class office extends Base {
      declare up_?: __.Key<__.Association.to<Books.publisher>>;
      declare up__ID?: __.Key<string>;
      declare up__up__ID?: __.Key<string>;
      declare ID?: __.Key<string>;
      declare city?: string | null;
      declare zipCode?: string | null;
    };
  }
  export class office extends _officeAspect(__.Entity) {}
  export class offices extends Array<office> {$count?: number}
}

Generated index.js for namespace bookshop

// Books
module.exports.Book = { is_singular: true, __proto__: csn.Books }
module.exports.Books = csn.Books
// Books.publishers
module.exports.Books.publisher = { is_singular: true, __proto__: csn['Books.publishers'] }
module.exports.Books.publishers = csn['Books.publishers']
// Books.texts
module.exports.Books.text = { is_singular: true, __proto__: csn['Books.texts'] }
module.exports.Books.texts = csn['Books.texts']
// Books.publishers.offices
module.exports.Books.publishers.office = { is_singular: true, __proto__: csn['Books.publishers.offices'] }
module.exports.Books.publishers.offices = csn['Books.publishers.offices']
// events
// actions
// enums
module.exports.Books.publisher.type ??= { self: "self", independent: "independent" }

This branch is again based on the fix for the draftable state (see #348)

Fixes #116
Closes #77
Closes #128

Let me know if you want to go forward with this branch/approach.

Regards,
Ludwig

@stockbal stockbal changed the title feat/refactor: use one csn only + other improvements feat/refactor: use only one CSN flavor + other improvements Oct 12, 2024
@daogrady
Copy link
Contributor

Hi Ludwig,

very impressive work, thanks for putting in the effort! As this is quite the intrusive change, I'd like to give this a more thorough look. I will be out of office for two weeks, starting Friday, so I won't be able to look into this (or other PRs) until mid November. Just so you know this is not being shelved, just put on hold until I have the time to give it a proper review. 🙂

Best,
Daniel

lib/visitor.js Outdated Show resolved Hide resolved
@daogrady
Copy link
Contributor

One particular problem we formerly overcame with our two-flavour-approach was dealing with the provenance of annotations. This is, as far as I am aware, generally an unsolved problem when you don't have access to the xtended flavour. Consider the following model:

@singular: 'A'
entity A {}

entity B: A {}

Using only the inferred flavour, both A and B would have the @singular: 'A' annotation. The generated code would therefore contain a duplicate class A:

// This is an automatically generated file. Please do not change its contents manually!
import * as __ from './_';

export function _AAspect<TBase extends new (...args: any[]) => object>(Base: TBase) {
  return class A extends Base {
    static readonly kind: "entity" | "type" | "aspect" = 'entity';
    declare static readonly keys: __.KeysOf<A>;
    declare static readonly elements: __.ElementsOf<A>;
    static readonly actions: Record<never, never>;
  };
}
export class A extends _AAspect(__.Entity) {}
Object.defineProperty(A, 'name', { value: 'A' })
Object.defineProperty(A, 'is_singular', { value: true })
export class A_ extends Array<A> {$count?: number}
Object.defineProperty(A_, 'name', { value: 'A' })

export function _AAspect<TBase extends new (...args: any[]) => object>(Base: TBase) {
  return class A extends _AAspect(Base) {
    declare x?: number | null
    static override readonly kind: "entity" | "type" | "aspect" = 'entity';
    declare static readonly keys: __.KeysOf<A>;
    declare static readonly elements: __.ElementsOf<A>;
    static readonly actions: typeof A.actions & Record<never, never>;
  };
}
// v ❌ v
export class A extends _AAspect(__.Entity) {}
Object.defineProperty(A, 'name', { value: 'B' })
Object.defineProperty(A, 'is_singular', { value: true })
export class B extends Array<A> {$count?: number}
Object.defineProperty(B, 'name', { value: 'B' })

did you consider this aspect already or do you see a way to handle this with only the inferred flavour available?

@stockbal
Copy link
Contributor Author

stockbal commented Nov 14, 2024

To be honest I didn't think of that. Considerung your sample I personally would say that such entity inheritence scenarios are not very realistic. Until now, I only encountered the use of aspects to enrich other entities with a set of annotations or fields (e.g. cuid, managed, CodeList to name a few). In that case we would be safe as one would not add @singular or @plural to an aspect.

That being said, the inferred model contains the annotations according to the rules of annotation propagation. In the past I personally had some issues because we had to annotate our entities in the database model and again in the service because of the missing propagation of @singular/@plural.

So there a times a propagation of @singular/@plural is welcome and times where it is not.

e.g.

// schema.cds
namespace db;

// -> MyBooks, MyBook
@singular: 'MyBook'
@plural: 'MyBooks'
entity Books {}

// -> Pubs, Pub
@singular: 'Pub'
@plural: 'Pubs'
entity Publishers {}

// -> BestsellingBooks, BestsellingBook
@singular: null // stop propagation and keep original name of entity
@plural: null // stop propagation and keep original name of entity
entity BestsellingBooks as select * from Books {}
// service.cds
using {db} from '../db/schema';

service MyService {
   // -> Books, Book
   @singular: null // stop propagation and keep original name of entity
   @plural: null // stop propagation and keep original name of entity
   entity Books as projection on db.Books;
   
   // -> BestsellingBooks, BestsellingBook (annotation propagation was stopped)
   entity BestsellingBooks as projection on db.BestsellingBooks;
   
   // -> Pubs, Pubs (via propagation)
   entity Publishers as projection on db.Publishers;
}

At the end, it is of course a breaking change and users would need to adjust their models to correct their generated types, but cds-typer would follow the documented rules of how annotations are propagated. At times it may force users to add more annotations to their models as before.

So 1) keep the xtended model for knowing the true origin of @singular/@plural and spare the users some thinking/writing or 2) add some additional documentation so it is clear how the annotations would affect the generated types. I don't think there is a good third option.

@daogrady
Copy link
Contributor

In that case we would be safe as one would not add @Singular or @plural to an aspect.

🤔 why is that? Aspects will be generated as classes by cds-typer, just as entities are. They therefore suffer from the same shortcomings our current inflection algorithm has, and thus can be annotated with custom inflection.

In the past I personally had some issues because we had to annotate our entities in the database model and again in the service because of the missing propagation of @singular/@plural.

not sure I grasp that either. Are you talking about having to repeat the naming for service-entities, which bears the risk of being inconsistent during renamings?

As for your proposed solutions: I agree, as I don't see any third solution either. I will bring this up in the next cds-typer sync and see if there is any preferred way forward.

@stockbal
Copy link
Contributor Author

🤔 why is that? Aspects will be generated as classes by cds-typer, just as entities are. They therefore suffer from the same shortcomings our current inflection algorithm has, and thus can be annotated with custom inflection.

The array class type is not generated for those, only for entities. Therefore I am not even sure why inflection is required for aspects at all. For example an aspect Status will result in a class Statu right now. Which would make sense for an entity type because otherwise the array class would have the same name.

e.g.:

// model.cds
aspect Status {}

entity MyStatus : Status { }
// index.ts
export class Statu extends _StatuAspect(__.Entity) {}

export class MyStatu extends _MyStatuAspect(__.Entity) {}
export class MyStatus extends Array<MyStatu> {$count?: number}

not sure I grasp that either. Are you talking about having to repeat the naming for service-entities, which bears the risk of being inconsistent during renamings?

Exactly. I added the annotations on db level because of unsatisfactory inflected names and then had to repeat the annotations again on service level. Consistent renaming in both db and service model was actually not an issue, at least not yet. We just expected the annotations to be inherited to services level, because of the mentioned propagation

@daogrady
Copy link
Contributor

Hi Ludwig,

we discussed your proposal internally and we would very much like to have this change merged. Especially the simplified CSN handling, inline compositions, and localization are a great benefit in themselves.
I think we should for now keep the xtended flavour for looking up the location of the inflection annotations. At the same time, we acknowledge the inconvenience of "double book keeping" these annotations cause. We hope to progressively phase out the xtended flavour in future development.

Is that an acceptable way forward for you?

Best,
Daniel

@stockbal
Copy link
Contributor Author

Hi Daniel,

that's really great news 😊.
One idea about keeping the xtended csn for the inflections. I could do a very early propagation of the singular/plural annotations, i.e. remove them from the new primary model - the inferred one - and transfer them from the xtended model. After that, the xtended model instance could be discarded. I could do this inside the constructor of the Visitor class.
What do you think about that?

Regards,
Ludwig

@daogrady
Copy link
Contributor

Hi Ludwig,

that sounds like another step to driving back xtendend to just the initialisation and streamlines the use of one CSN flavour during the remaining generation process. So sounds good to me! 🙂

Best,
Daniel

@daogrady
Copy link
Contributor

daogrady commented Dec 4, 2024

In that case we would be safe as one would not add @Singular or @plural to an aspect.

I just realised there seems to the hypothetical szenario of adding @singular/ @plural to aspects after all. Assuming the generated singular or plural form of an aspect collides with the name of an existing entity, the user would have to add these annotations.
I believe the approach you are currently following is still robust against generating the inflected forms to cds aspects, correct?

(my heartfelt condolences for the GitHub user plural who receives a notification whenever I forget to put @plural in backticks)

@stockbal stockbal marked this pull request as ready for review December 6, 2024 20:32
@daogrady
Copy link
Contributor

Hi Ludwig,

quick update: we discussed this internally and agree with your proposed solution.

Best,
Daniel

@stockbal
Copy link
Contributor Author

stockbal commented Dec 12, 2024

Hi Daniel,

ok...
And with my proposed solution you mean synching the namespace, or leaving it as is? 😊.

Regards,
Ludwig

@daogrady
Copy link
Contributor

Hi Ludwig,

sorry, I meant the post I had already agreed to before, but now got confirmation of the PO as well:

I absolutely see your concerns and yes, in the worst case, users would end up with up to three names for one entity in their model (singular, plural, and the namespace to top it off). But to me, the path forward you proposed makes sense. Still, I should be able to get a hold of some higher-ups today, so I can also try to get their input on the matter.

Best,
Daniel

@stockbal
Copy link
Contributor Author

stockbal commented Dec 12, 2024

Hi Daniel,

sorry, I am still at a loss as to if I still have an open task in this PR or not😅.

P.S.: Maybe I am little slow today as I was sick the last few days.

Regards,
Ludwig

@daogrady
Copy link
Contributor

Hi Ludwig,

sorry for the confusion again. 😃
Please leave the namespace for scoped entities at the original name as it is found in the CSN and do not apply any @singular/ @plural rename to it.

Best,
Daniel

@stockbal
Copy link
Contributor Author

Then this PR is ready for review now

@daogrady
Copy link
Contributor

Hi Ludwig,

thanks for finishing this feature! I'm afraid I won't be able to give this a proper review before the holidays, as other tasks unfortunately currently take precedence, but if I don't get to it today or tomorrow, this will be one of the first things on my plate in the new year.

Best,
Daniel

@daogrady daogrady added the keepalive Will not be closed by Stale bot label Dec 19, 2024
@daogrady daogrady changed the title feat/refactor: use only one CSN flavor + other improvements feat/refactor: dedicated classes for texts and inline compositions + refactoring Jan 8, 2025
@daogrady daogrady changed the title feat/refactor: dedicated classes for texts and inline compositions + refactoring feat/refactor: dedicated classes for texts and inline comps Jan 8, 2025
Copy link
Contributor

@daogrady daogrady left a comment

Choose a reason for hiding this comment

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

Thank you for this contribution!
Looks very good and even if it didn't bring support for locale classes, having weeded out several huge "FIXME this is a hack!" blocks makes this a valuable change.

I have added a few remarks and mostly annotated them with severity markers. Especially the syntax ones are largely dust to me.

lib/csn.js Outdated Show resolved Hide resolved
lib/file.js Outdated Show resolved Hide resolved
lib/file.js Show resolved Hide resolved
lib/file.js Outdated Show resolved Hide resolved
lib/printers/javascript.js Outdated Show resolved Hide resolved
#inheritedElements = null

/** @returns set of inherited elements (e.g. ID of aspect cuid) */
get inheritedElements() {
Copy link
Contributor

@daogrady daogrady Jan 8, 2025

Choose a reason for hiding this comment

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

Have you considered property renaming in this context? I.e., does your code exhibit well-defined behaviour for when a property is present in a parent, but also in the entity itself (but possibly with a changed type)?

entity A {
  name:String
}

entity B: A {
  name:Integer
}

As of today, this is a known restriction. Even if you do not explicitly solve the issue, this could be an opportunity to error out if we find redefined properties.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No I haven't considered them - why would anyone do that 😶‍🌫️. No proper solution comes to mind right away, I would have to think about it some more.
If I understand you correctly I could implement the following approach though, to error out and stop the type generation in case of a type mismatch.

// visitor.js - #aspectify
  for (let [ename, element] of Object.entries(entity.elements ?? [])) {
    const inheritedElem = inheritedElements?.get(ename)
    if (inheritedElem) {
        const type = stringifyType(element.type)
        const inheritedElemType = stringifyType(inheritedElem.type)
        if (stringifiedType === inheritedElemType) {
            continue
        } else {
            throw new Error(`Type '${type}' of element '${entity.name}.${ename}' does not match the type '${inheritedElemType}' from the parent`)
        }
    }
  }

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't know what the architectural reasons would be, but it has happened in the past. 🙂

Copy link
Contributor

Choose a reason for hiding this comment

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

P.S.: I asked this mainly out of curiosity. As it is beyond the scope of your PR, this can totally be done at a later point in time and by someone else.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Then I would actually table this for later if this is ok for you

Copy link
Contributor

Choose a reason for hiding this comment

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

Absolutely! 🙂

lib/resolution/resolver.js Outdated Show resolved Hide resolved
lib/resolution/resolver.js Show resolved Hide resolved
lib/resolution/entity.js Outdated Show resolved Hide resolved
lib/visitor.js Show resolved Hide resolved
Copy link
Contributor

@daogrady daogrady left a comment

Choose a reason for hiding this comment

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

Thank you for this contribution!
Looks very good and even if it didn't bring support for locale classes, having weeded out several huge "FIXME this is a hack!" blocks makes this a valuable change.

I have added a few remarks and mostly annotated them with severity markers. Especially the syntax ones are largely dust to me, so feel free to just mark them as resolved without change if you feel strongly about your syntax choices.

@daogrady
Copy link
Contributor

daogrady commented Jan 9, 2025

@stockbal seeing that all immediate points have been addressed, do you consider this PR done and ready for merge, or do you need to make any more changes?

@stockbal
Copy link
Contributor Author

stockbal commented Jan 9, 2025

No, unless you still found something that I should change, the MR is done from my point of view.

@daogrady
Copy link
Contributor

daogrady commented Jan 9, 2025

Great! Then thanks once more and let's merge 🚀

@daogrady daogrady self-requested a review January 9, 2025 14:20
@daogrady daogrady merged commit d32426a into cap-js:main Jan 9, 2025
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
keepalive Will not be closed by Stale bot
Projects
None yet
2 participants