-
-
Notifications
You must be signed in to change notification settings - Fork 11
Description
This proposal is to add a mechanism to support the inheritance of annotations with the ability to merge annotations from multiple field definitions
A Partial Mixin would serve as a complement to a Type Mixin, which are used to enable the inheritance of fields.
The type mixin documentation currently states:
A model can use multiple mixins as long as their field names don't conflict.
This would alter that paradigm by providing a second mixin option that functions similar-ish to the abstract modifier available to Typescript classes. Unlike a Typescript abstract, it is proposed to be implemented as a standalone concept rather than as a modifier.
In contrast to a type mixin, a partial mixin:
- Would not require a field to be fully defined — LSP and other tools should assume that the field will be defined and finalized elsewhere
- Cannot include a data type definition — extends fields with annotations only
- Multiple definitions against fields with the same name are merged — fields can, and are expected to be duplicated across
partialdefinitions
The type mixin would retain the current requirement that multiple type mixins applied to a type or model cannot cause conflicts due to having duplicated field entries.
Example
Allow the core definition of a field in a type or model to focus on the essentials ...
// Very clean and readable definition of the core data structures
type Base with BaseAcmeEditor, BaseBetterEditor, BaseGolang {
/// Primary Key
id Int @id @default(cuid())
/// Creation Date
created Datetime
/// Updated Date
updated Datetime
}... whilst functions are split into independent and reusuable blocks
// Currently running the Acme CMS ... but it keeps blowing up.
partial BaseAcmeEditor {
id
@acme.volatitle('Primary Key')
@acme.icon('dynamite')
created
@acme.volatitle('Created on')
updated
@acme.volatitle('Updated on')
}
// We are replacing it, but need both in parallel for now ...
// I really hope we can easily remove those buggy
// Acme entries when the migration is done
partial BaseBetterEditor {
id
@better.title('Primary Key')
@better.icon('key')
@better.hidden()
created
@better.title('Created on')
updated
@better.title('Updated on')
}
// At least we can use ZenStack with a plugin to
// generate structs for our caching microservice ...
partial BaseGolang {
id
// Hypothetical need to transform type
@golang.type('String')
@golang.parse( ... )
created
// .. or to use a different field name
@golang.map('CreatedUTC')
// .. or to ensure working with UTC immediately
@golang.default('time.Now().UTC()')
updated
@golang.map('UpdatedUTC')
}More details ...
Rationale
Primary Motivation
The most salient aspect of this proposal is the notion to extend the newly introduced concept of a mixin1 beyond only being a type mixin. It is also to encourage doing that early to avoid later confusion on what new concepts are.
I believe considering mixins as a more, "generic container that can be remixed and applied to different entities" concept, will be a needed conceptual and technical building block for implementing ambitious ideas such as #563. The current type mixin alone may not accommodate some of the challenges involved, especially without muddying the definition of what a "type" is further.
In an issue tracking the implementation of inheritance abstractions (#783)2, @AmruthPillai commented:
[Would] it be wise to use another keyword other than
typeas Prisma might have plans on using the same keyword for relational databases, but for a different construct?
As ZenStack diverges further away from PSL, the topic of what Prisma does with a keyword becomes somewhat less relevant. There are still strong conceptual ideas associated with what a "type" is to developers to consider though.
The current challenge is that there is currently no clear way to "mix-in" groups of annotations to final field definitions. Especially not in any way that emulates the composability of models which can be comprised of multiple type definitions. Field definitions are all or nothing once defined.
This is an important feature of type mixins however, as it directly supports having Strongly Typed JSON Fields. It is very important to maintain that approach of having a single place where the final "shape of the data" is defined. If a type could be defined in multiple places, it risks becoming inconsistent and unreliable.
Conflating abstraction and inheritance features as a generic concept too tightly with the notion of a "type" may also increase friction with developers understanding those features, and slow adoption. Instead, we can move the association of the mixin concept to the with keyword instead of the current emphasis on the type keyword. This allows the concept of "remixing" to be applied more liberally.
My current view is that a type mixin is, and should remain, tightly coupled to the Strongly Typed JSON Field feature, which largely defines data structures (the final shape). The addition of a partial mixin would support creating modular definitions of how data structures are interacted with (how the final shape can move). This would be done by focusing on creating a container that defines related groups of annotations as "mix-ins".
Benefits
Immediate Benefits
There are two broad areas the addition of partial mixin to ZenStack would benefit:
- Separation of concerns — maintain integration of different functions independently
- DX — improved readability and maintainability
Notable highlights:
-
DRY-er schema definitions
Consider two model definitions that have the same field with the same name, policy and other annotated requirements, but use a different data type. An
idfield could an auto-incrementedInton some models and an auto-generated UUIDStringon the others.Currently, a developer would have to maintain two individual type mixins to support this. This is regardless of if all other requirements are the same. The addition of a partial mixin would provide a way to prevent needing to duplicate shared attribute annotations.
-
Security benefit: Composable definition of access policies
Continuing with the example above, the
idfield in both types would likely need a policy that allows creation but prevents further updates. Entire policies can be described in a partial mixin and repeatably applied across multiple type mixins and models. -
Improved flexibility
Can choose to run operations against a schema with only a subset of the available defined attributes applied. This can be done just by removing a mixin rather than commenting out entire blocks. Enables configuring different behaviors based on contexts (edge vs. origin, private vs public, dev vs prod, aws vs gcp, etc.)
-
Improved mobility
Similar to flexibility to change how a schema is deployed, the same flexibility can help improve mobility between different solutions. Locating all annotations per vendor/application/format in it's own partial means developers don't need to go looking for every single line to enable, or disable relevant annotations.
Future Benefits
There are also opportunities for further benefits that adding partial mixins would enable exploration of in the future. Prioritizing adding the concept of a partial in a basic form would be beneficial to:
- provide the above immediate benefits in terms of DX (readability especially) and composibility (increase flexibility whilst staying DRY)
- introduce the concept of having multiple mixin options to developers in one shot to avoid later confusion
- adding another differentiator over PSL whilst remaining a superset syntax
As such, the following ideas may be best left out of scope for any first implementation of partial mixin support.
Possible future opportunities include:
-
Apply partial mixins based on conditions defined in the schema
If a
@conditionattribute is introduced in the future, the benefits listed under "Improved flexibility" could be applied automatically. For example:partial Example { // Oh look, an example @@condition(flag: '') @@condition(dbEngine: '') }This could include filtering based on factors such as:
- Feature flags
- Environment variables
- Database type or config
- Use alternative @db.x annotations for NoSQL vs Postgres vs SQLite
- Deployment context
-
Apply annotations to multiple fields using regex (or similar) field names
Example: Apply access annotations to all fields prefixed with something (eg.
internal_*). Security is improved simply by following internal naming conventions.Example: Add default annotations to all fields in models that inherit the partial mixin
Example: Add a plugin annotation to all fields including
xyz -
Templates and transformers support
If any form of templates support is introduced in the future, it may be possible to create basic macro-like functionality by combining various features of ZenStack with partial mixins and templates.
This may even allow definition functionality directly in a schema to achieve outputs that would otherwise need a custom plugin. Particularly beneficial if regex field names are supported in a partial mixin
Implementation
Avoiding errors
To prevent unintentional errors by developers, it should only be possible to apply a partial mixin where every field in the partial also exists in the type or model it is applied to.
For example, the following should result in an error:
partial Wompinator2000 {
womp_womp_2 @icon('not-found')
}
model Wompinator with Wompinator2000 {
womp_womp String
}Future iterations could include opt-out mechanisms to allow only applying annotations for fields that exist in a model and ignoring missing fields. This should be explicitly opt-out though if implemented as an option.
Conflict resolution
It is probably reasonable to document this as an advanced feature. Developers will need to be aware of how they use plugins and partial mixins, both when writing:
- schema definitions — i.e. may need to be mindful of the order that mixins are applied
- plugins — i.e. avoid assuming knowing the field datatype unless absolutely required
Some basic safeguards may need to be put in place to catch conflicts and bugs early. For example:
- Error or warn if duplicate annotations are found — i.e. multiple
@defaultannotations applied - Allow plugins to specify when included custom annotations require a specific datatype, and validate that is reflected when a partial mixin is applied to a model
Considerations
Limitations, breaking changes or other impacts
None identified currently, besides development time required. Please add any as they are identified.
Alternative implementation options
It could be possible to just allow the current type mixin to support merged fields. I personally believe the benefits of ensuring a type mixin is strongly defined by default outweigh having a reduced number of options to learn (see Rationale section).
It could also be possibly to reuse the abstract keyword from V2 to modify the behavior of type mixins. I think it is easier to conceptualize the idea of there being multiple mixins available, than how and when to modify mixins.
Reusing a keyword may also introduce confusion for developers migrating between versions. It is also for this reason that a new keyword (partial) has been proposed instead of reusing abstract as the name of a new mixin option.
Alternative syntax options
The provided example assumes that a partial mixin would be applied using the same syntax and keyword (with) as a type mixin. Consideration should be given to whether that could lead to any drawbacks. There are two other options (and a third hybrid, combining both options):
-
Use a different keyword to apply a partial mixin to a
typeormodelI am not particularly keen on this option, as it dilutes the singular concept of a mixin. Using the same keyword (
with) for all mixins ensures it becomes a familiar concept, regardless of what is being mixed in.partial Buttercup { id // Adding nice stuff ... } type World { id // Adding boilerplate stuff ... } type Hello with World and Buttercup { // ^ // |-- No idea what the hypothetical // |-- keyword should even be ... id Int @id @default(autoincrement()) } -
Add partial mixins after the
typeormodeldefinitionThis would have the benefit of ensuring any mixins that include typed field definitions (a final shape) have priority when viewing a schema. It would also allude to the fact it is applied after the core model structure has been defined.
partial Demo { id // ... } partial Lition { id // ... } model Darkness with Hello { friend String @default('old') } with Demo, Lition // Wow, this poor `id` field is getting a real workout
Footnotes
-
A mixin may already be intended by the ZenStack team as a more generic concept. If so, clarification may be required in the beta documentation and relevant comms. ↩
-
I have made a massive assumption here on the purpose of this open issue, and could be wrong. Apologies if I have! Although it is titled "Non-model types", it appears to me a loosely coupled collection of abstraction requirements that may need similar, but slightly tailored solutions. My identification of its' importance is on the basis it remains in the 2.x milestone and is tagged for "feedback" and "in-progress", but I also recognize internal direction or views may have diverged from the public facing records associated with V2 as V3 is developed. ↩