Closed rafaferry closed 1 year ago
Yeah I had such a group mechanism already on my list but didn't find the time yet. Totally makes sense!
Indeed @f.exclude
is on compile level, what you need is on runtime level. I'd implement it as you explained, except that to rename filter
to group
, and maybe change the third argument of partial*()
a bit (into a option object).
May I help you?
How hard is it? I was scanning through the code and found this function:
export function jitPartial<T, K extends keyof T>(
fromFormat: string,
toFormat: string,
classType: ClassType<T>,
partial: { [name: string]: any },
parents?: any[]
): Partial<{ [F in K]: any }> {
const result: Partial<{ [F in K]: any }> = {};
const jitConverter = new JitPropertyConverter(fromFormat, toFormat, classType);
for (const i in partial) {
if (!partial.hasOwnProperty(i)) continue;
result[i] = result[i] = jitConverter.convert(i, partial[i], parents);
}
return result;
}
I think the filter could be implemented adding a condition in this line:
if (!partial.hasOwnProperty(i)) continue;
There is a way to access meta information here so I can check for the group config?
Hi, following this issue as we need a "group/filter" validation in our application too.
I just discovered your package and sounds really promising for us to replace class validator / serializer with yours...
If you need help, don't hesitate :)
@rafaferry unfortunately, not that straightforward since i
in partial
can be a deep path, e.g. user.settings.color
, so that loop is not the right place. It needs to be in JitPropertyConverter.convert()
where we have the PropertySchema. PropertySchema needs to be extended to hold the group information plus the @f
decorator needs to be updated to support setting a group name. I'm going to implement it once I find the time.
@marcj This would be awesome. I'll rewrite my mongo wrapper (https://github.com/j/type-mongodb) to utilize this library. I really want to implement toJSON()
/ toObject()
in my mapping but with the ability to exclude fields from the call.
I'd like to exclude _id
, but include a get id() { ... }
for field remapping on a toObject
type of call.
@j, do you have an example/pseudo-code? I don't completely get what you mean.
Yeah I didn't
class MyEntity {
@f.primary().mongoId()
_id: string;
// excluded by default
@f.exclude()
get id(): string {
return this._id.toHexString()
}
}
const myEntity = new MyEntity();
myEntity._id = new ObjectId();
const reader = classToPlain(MyEntity, myEntity); // { _id: "..." }
// exclude / include
const public = classToPlain(MyEntity, myEntity, { exclude: ['_id'], include: ['id'] }); // { _id: "..." }
Or the group route....
class MyEntity {
@f.primary().mongoId().groups('document')
_id: string;
@f.groups('document', 'public')
name: string;
// excluded by default
@f.exclude().groups('public')
get id(): string {
return this._id.toHexString()
}
}
const myEntity = new MyEntity();
myEntity._id = new ObjectId();
const public = classToPlain(MyEntity, myEntity, { group: 'public' }); // { _id: "..." }
Also, I want to note that it'd be nice for all this functionality to be able to be programmatically defined:
const schema = createClassSchema(MyEntity);
schema.addProperty('_id', f.type(ObjectId), { groups: ['document'] });
schema.addProperty('id', f.type(String), { getter: true, groups: ['public'] });
schema.addProperty('name', f.type(String), { groups: ['document', 'public'] });
Again, this is all sudo code. Right now marshal.ts is so tied to mongo, but I want to use an already existing mongo layer we have and just add marshal to it.
Awesome work btw. I've probably told you this though. :P
@marcj Groups is going to be awesome! I thought of a few more use-cases though. Let's say database-library
uses marshal.ts
, but I want to also bring in another library, api-library
. database-library
wants to map classes to their database form, whereas api-library
wants to map classes to their JSON form (response). Both would conflict. Groups is a partial solution to this, as long as both libraries are aware of groups, and almost enforce it (hard to do unless you own both libraries). But it'd be really cool to be able to create a context per library so they don't conflict each-other. Maybe this can be done by telling marshal
which metadata storage to use in stead of hard-code them, then on xToY
methods, to use that storage? Or to create a factory for creating schemas. So pretty much a way for two libraries to map a class separately, both utilizing marshal.ts
This is probably a re-work of how the marshaler is created since it currently relies heavily on module globals, but if it was more of a serializer factory, then other libraries that do class serialization can benefit from it without conflicting so the user can also use it on their own end.
i.e. (sudo code)
// this is all for use in `database-library`
import { createMarshaler } from '@marcj/marshal-core`;
export const marshal = createMarshaler();
// you can still do this... but this will only register on the above "marshaler".
marshal.registerConverterCompiler('class', 'MyTarget', 'date', (setter: string, accessor: string) => {
return `${setter} = ${accessor}.toJSON();`;
});
marshal.registerConverterCompiler('MyTarget', 'class', 'date', (setter: string, accessor: string) => {
return `${setter} = new Date(${accessor});`;
});
// then in other files, the `database-library` can use this marshaler to create it's own decorators for user-land,
// essentially making the user unaware that it's using this library.
then @marcj/marshal
can create it's own marshaler and register common converters if wanted.
If we want to go on with feature dreams, you could implement plugins and remove all mongo/moment/other library specific converters and have something like:
import { use } from '@marcj/marshal';
import { mongoPlugin } from '@marcj/marshal-mongo';
// this adds object id decorators, etc.
use(mongoPlugin);
// alternatively, other libraries using `core`
import { createMarshaler } from '@marcj/marshal-core';
import { mongoPlugin } from '@marcj/marshal-mongo';
export const marshal = createMarshaler();
marshal.use(mongoPlugin);
I'd be all over this if this was all possible.
@j could you post an actual detailed use-case? I can't come up with one.
Both would conflict
Why? They would have each their own class definitions and thus their own class properties schema. I don't see how this would conflict. Or do they share classes via a third common
package?
I don't understand your use-case with those 2 libraries with your first code example as it suddenly jumped to compiler templates, which are separated from the decorator and class schemas. You indicated with that first example that Marshal could store compiler templates in an local instance (instead of storing them globally), which is then used in serialization methods (e.g classToMongo
, mongoToClass
). While I agree that this could be done this way and is probably more encapsulated, I don't see what problem this would solve.
If I understand you right, you want to be able to add a new serialization target (like a custom mongodb layer). This is already possible as seen in the README. What's currently not possible is to attach additional information to the PropertyCompilerSchema
, not by changing @f
decorator nor by extending the PropertyCompilerSchema
directly or by using a map ref. So you have currently no way of resolving/retrieving custom decorator values in your compiler templates (or validation templates). To allow this I would have to add a data container to PropertyCompilerSchema
which can be filled by @f
or via custom decorators. This new data container needs to be added either way since I want to support sooner or later MySQL/PostgreSQL/SQLite as well, which makes it absolutely necessary to extend PropertyCompilerSchema
class with custom values via the @f
decorator or custom decorators. Wouldn't this solve your use-case as well?
Btw, what functionality do you miss in the current marshal-mongo library? Maybe we could just enhance it instead of reinventing the wheel.
@marcj To put it simply... I have a library, type-mongodb
which at the moment hydrates mongo documents into class form. Here, I'd like to continue using my decorators as is, but use this library for hydration.
Secondly, I also have my main API server, for which I want to use marshal.ts
to do JSON serialization for my events layer and HTTP responses.... So the same class would have marshal.ts
metadata, but I want two different outputs.
I could achieve this with groups, yes. My type-mongodb
layer can just add a "mongodb" group to every field, then my API server, I'd use marshal.ts
directly and would have to use another group (i.e. "json"). By default, marshal.ts & groups, I'm assuming serializing without saying which groups would include everything (not what I'd want). But if type-mongodb
could use it's own context separate from my API server, then they don't have to know about eachother at all since it's not shared metadata.
Simple example:
import * as TypeMongoDB from 'type-mongodb';
import * as GraphQL from '@nestjs/graphql';
import { f, classToPlain } from '@marcj/marshal';
@GraphQL.ObjectType()
@TypeMongoDB.Document({ collection: 'users' })
class User {
@TypeMongoDB.Field()
_id: ObjectId;
@GraphQL.Field()
@f
get id(): string {
return this._id.toHexString();
}
// i'm not sure how Marshal.ts uses setters, but if you wanted to go from "plain" / "json"
// form to the class, I'm assuming this is how. you'd do it
set id(id: string) {
this._id = new ObjectId(id);
}
@TypeMongoDB.Field()
@GraphQL.Field(() => Date)
@f
createdAt: Date;
}
// this would be a "json" (plain) appropriate form of User.
const plain = classToPlain(User, user); // { id: "...", createdAt: Date("...") }
// internally, `TypeMongoDB.hydrate` is it's own hydration layer on top of `marshal.ts` and doesn't
// know about `get id()` / global mapped fields since it'd use its own "context".
const user = TypeMongoDB.hydrate(User, { _id: new ObjectId(), createdAt: new Date() });
So the above example shows two different input/outputs for serialization, one being the mongodb style where I use _id, but the other, for JSON where I want to essentially rename _id
. Since I own both libraries, I could just use groups and call it a day. Just thinking this would be cleaner if possible.
This is just an example. Realistically, I'd probably refactor type-mongodb
to use groups (when available) and support mongoose style virtuals and have a dm.toPlain(User, user)
method
I understand now, thanks.
One of Marshal's fundamental concepts is that classes should be agnostic to its serialization targets. Having an ObjectId
as property type tightly couples this class to backed only and it can never be used in the frontend or other places (like CLI tools, core library, etc). This is kinda against the philosophy of Marshal, so that's a reason why you are not able to easily cover your use-case. But that just as a side note.
It seems for this use-case a naming strategy/mapping feature would better fit where you can simply say that id
is named _id
in mongo. Or a hook system where you can register callbacks for a particular serialization target that will be called once a serialization has been done (where you then can simply rename properties). Using setters/getters rewiring stuff looks like a hack to me. A better way for the moment would be to simply rename property names using a custom function on top of classToPlain
, e.g. const plain = mongoToPlainWithNamingStrategy(User, user)
.
With marshal-mongo your use-case would look like this:
import { f } from '@marcj/marshal';
import { mongoToPlain } from '@marcj/marshal-mongo';
@Entity('user')
class User {
@f.mongoId()
_id: string;
@f
createdAt: Date;
}
function mongoToPlainWithNamingStrategy(classType: ClassType<any>, item: any): {
const plain: any = mongoToPlain(classType, item);
if (plain['_id'] && !plain['id']) {
//_id is a string already thanks to marshal-mongo
plain['id'] = plain['_id'];
delete plain['_id'];
}
return plain;
}
const plain = mongoToPlainWithNamingStrategy(User, { _id: new ObjectId(), createdAt: new Date() }));
The naming strategy feature would reduce the use-case example down to this:
import { f } from '@marcj/marshal';
import { mongoToPlain } from '@marcj/marshal-mongo';
@Entity('user')
class User {
@f.mongoId().named('mongo', '_id')
id: string;
@f
createdAt: Date;
}
const plain = mongoToPlain(User, { _id: new ObjectId(), createdAt: new Date() }));
const mongoRow = plainToMongo(User, plain);
//mongoRow._id = plain.id
Looks like pretty much what you need. This is again a feature which will be sooner or later integrated as I need that for RDBMS support (where you usually have snake case names)
That's confusing. I'd expect _id to be an ObjectId (which you have in mongoToPlain), but it's typed a string. How do I get both (how mongoose does it as an example). model._id
= ObjectId and model.id
= hex string.
I'd not expect to have any MongoDB driver special types (Binary, ObjectId, UUID,...) in my typescript class. This is again against the philosophie of Marshal (and OR*M principles). You can create a serialization target that doesn't touch it and keep the types, but then you should register those as custom Marshal types and register a compiler template from class -> plain for those. Custom types are not supported yet (as I don't have a use-case like that). Using this plus naming strategy feature would allow you to easily cover this use-case, however they are not there yet so your best bet is grouping with the getter workaround.
@marcj I guess I haven't dug into exactly how this library works. Going between the types, I wasn't sure if it's taking an ObjectID, converting it to a string, then back to a mongo document each time instead of just re-using the same object id/other types, which could be a performance thing depending on how the type converts.
Most O*Ms keep a reference to the internal entity/document so that it doesn't need to keep transforming between the two, because In some cases, I'm sure there are conversions can potentially be expensive. Again, I'm not sure if you keep a reference to the original type or not.
For me, I tend to do things like:
class BaseDocument {
_id: ObjectId;
get id(): string {
return this._id.toHexString()
}
I'm not alone with this either.
https://github.com/mikro-orm/mikro-orm https://github.com/Automattic/mongoose https://github.com/typeorm/typeorm (they just use internal types on the class)
But I can see how hiding internal types and using common types in a class would be beneficial.
In which case, the naming strategy could be nice to have.
And another question I want to ask if I end up implementing this serializer in my "ODM" (more like a document mapper ATM): Can I programmatically use all marshal.ts features...
const schema = createClassSchema(ExternalClass);
schema.addProperty('id', f.type(String));
schema.addProperty('version', f.type(Number));
schema.addProperty('lists', f.array(Number));
In the end of the day, I want to see a serialization library that focuses on performance so that any O(R|D)M library would want to use it for their internals. Which is why I think it'd be cool having contexts so internal serialization can happen away from user-land code.
Yeah, I see the use-case for backend/NodeJS-only serializer/db abstractions. But Marshal is an universal serializer/db abstraction which allows you to use the same class at frontend, cli, shared libraries etc as well, which would break if you're required to use db driver types in your class. The class is then tightly coupled not only to your backend/node.js, but also to a certain database and you need to write the exact same class again with slightly different types if you want to use the data structure in frontend/cli/whatever again, which is something I want to avoid in isomorphic Typescript projects. In this regard Marshal has a new principle and a comparison with Typeorm/mongoose is not expedient, especially if you consider that Typeorm has basically no mongodb support (except some experimental super basic stuff). Also, having ObjectId in your class is something I consider bad practise since I don't know a single project that actually uses the ObjectId representation (converting between the two is fast) and not the string hex representation. All of them used the hex string, and many had always the same boilerplate of converting between ObjectId to hex string all over the place. ObjectId is probably the best candidate you can imagine to abstract away when talking about database abstractions. Having this implementation detail in your use-land code is like having different type of float precisions of PostgresSQL in your code - premature optimization at its finest. For which use-case do you use the ObjectId? My wild guess is you don't have one 😜.
Can I programmatically use all marshal.ts features
Yes, you can define a ClassSchema
entirely programmatically using createClassSchema
and then use classToPlain/plainToClass on it, as described in the docs.
@marcj The only use-case is that it's typed and not just a string, but due to compilers doing it's internal validation, not really a problem. You're right about ObjectID use-case. You can get around it by doing string comparisons for equals checks, etc.
We don't share classes between the front-end and backend. We tried, but just didn't scale. We use GraphQL and typescript generators (https://graphql-code-generator.com/) to create typed client interfaces automatically based off the schema, and our server uses classes for document mapping as well as generating GraphQL schema automatically. Sharing in this case wouldn't really work as they are quite different. Server may use GridFS, but give a property to the client that returns a URL to get that GridFS file, etc. We try to do most things on the server (currency conversion, number/text formatting, intl, etc), then let the client choose what it wants so it does less, but using code generators with client schema gives them the ability to add custom stuff if needed. Only simple use-cases would work for isomorphic apps.
I'll give it a go and see how far I can bake marshal.ts
into my document mapper and use the groups, etc.
We tried, but just didn't scale
Yeah because you didn't have Marshal. I tried as well and failed, that's why I created Marshal.
Only simple use-cases would work for isomorphic apps.
I wouldn't agree here. I built https://deepkit.ai which is a pretty complex application (server,cli,library,frontend all written in TS, sharing many and very complex models) as well as other complex isomorphic apps, and it scales perfectly fine, but only with Marshal.ts. There's no other library out there which would have scaled. Having a code generator wouldn't scale as well since it generates a lot of overhead and complexity, this complexity increases and increases the more complex and more models you have.
I'll give it a go and see how far I can bake marshal.ts into my document mapper and use the groups, etc.
When you only want to exclude certain properties depending on the serialization target then @f.exclude
is probably better as its faster (generates less code). For the use-case you described like excluding _id
for classToPlain, or excluding id
for classToMongo, f.exclude
is the way to go.
Sorry, maybe for us, after switching to GraphQL completely, we kept clients super lean and dumb. Mixed entity classes that had dependencies on things such as @nestjs/graphql
meant the client had to either include that dependency or use a webpack hack to make it work.
If you have plain entities, I'm sure it can be okay. But in our case, we don't really want to share all the code. The clients get to do what they want to do, and the server gets to do what it wants to do.
And for using the typescript GraphQL generator. CI does it and we currently have about 2-3x more models than deepkit and it hasn't been an issue what-so-ever. It ended up being a breath of fresh air to not deal with client / server compatibility.
Anyway, deepkit looks sweet. :). And you're the only contributor. You must be a super busy person. Haha
And yeah, the exclude will work for now. I'm just going to build plain object serialization into the mongo library instead of allowing me to add fields in "user-land" code. Again, if I could create contexts of the two, I'd just do that. I'm tempted to just clone the library to have the separation, but I'm too lazy.
Yeah, I tried code generators as well, but it's not a joy. GraphQL neither. Deepkit has about 75 models and none is duplicated. I agree that not caring about compatibility is like a breath of fresh air. You know what makes the fresh air even better? Getting that feeling without the extra step of using a heavy code generator or GraphQL. 😜
Again, if I could create contexts of the two, I'd just do that
You really don't need that for your use-case.
Yeah, I tried code generators as well, but it's not a joy.
Haha. Our team tried both, and everyone felt better going the generator route. GraphQL for us has been night and day compared normal REST apis. Using a dataloader with mongodb (that sucks at joins) has been great. That's the joy of programming.. Many ways to do a similar thing. I can say that if I was coding my own project, front-end and back-end, I'd probably try using isomorphic JS. In a team environment, it's been nice to spec out a feature, allow front-end developers to mock the GraphQL responses, generate typings, and get to work while back-end is plugging away functionality.
Anyway, @marcj what's a way to take a plain object, and merge it into a class? I'm feeling like the only way at the moment is to literally convert the model to plain object, do a deep merge, then back.. (yikes). i.e.)
class user = new User();
user.firstName = 'John';
user.lastName = 'Doe';
// assume that the "merge" props can be dynamic.
classToClass(User, user, { lastName: 'Doe' });
Later, I can break this functionality and try the patch stuff. I'd really like to dig into dirty tracking to pull an object from mongo, hydrate it into a class, change any properties (nested, in arrays, etc), then save it and internally get only the saved items. That's the dream, but probably not reality... hence no one has really done it in the JS world.
You probably need a library like dot-prop
if you have deep patches, if not a simple assignment will do it.
import {set} from 'dot-prop';
import {partialPlainToClass} from '@marcj/marshal';
const user = new User();
user.firstName = 'John';
user.lastName = 'Doe';
user.updated = new Date();
const plainPatches = {
updated: "2020-07-01T22:41:14.992Z",
lastName: 'Smith'
}
const classPatches = partialPlainToClass(User, plainPatches);
for (const [i, v] of Object.entries(classPatches)) {
set(user, i, v); //this for deep patches support, like `config.foo`
user[i] = v; //or that
}
If you need a copy of the class instance, use cloneClass
before. If you want to maintain unchanged references use applyPatch
change any properties (nested, in arrays, etc), then save it and internally get only the saved items
Sounds like you need applyAndReturnPatches
, but not sure. Not the most performant way to do dirty-checking though. Will implement a super-fast JIT dirty-checking when I find the time to implement UoW in Marshal's db abstraction.
@marcj I get an error: TypeError: _parents.slice is not a function
. In this case, _parents
= ToClassState
instance
test('patch2', () => {
class User {
@f firstName!: string;
@f lastName!: string;
@f updated!: Date;
}
const user = new User();
user.firstName = 'John';
user.lastName = 'Doe';
user.updated = new Date();
const plainPatches = {
updated: "2020-07-01T22:41:14.992Z",
lastName: 'Smith'
}
const classPatches = partialPlainToClass(User, plainPatches);
const copy = cloneClass(user);
for (const [i, v] of Object.entries(classPatches)) {
copy[i] = v;
}
expect(user.lastName).toBe('Doe');
expect(copy.lastName).toBe('Smith');
});
works fine for me on master
.
I realized I'm doing a shallow merge anyway, so I think I can get it to work just by converting class to plain, merge, then plain to class. I'm not really using merge anyway. I just want to get everything working to test (as well as benchmark ;))
Will implement a super-fast JIT dirty-checking when I find the time to implement UoW in Marshal's db abstraction.
That sounds pretty sweet. Maybe use json patch style and a "json patch to mongo update" type of utility function for consumers.
I'm getting TypeError: _parents.slice is not a function
on master on mongoToClass
.
ATM I'm cheating and using the "any" type for most things (minus embedded documents / embedded document arrays).
Maybe use json patch style and a "json patch to mongo update" type of utility function for consumers.
JSON patch is incompatible with mongo's patch system (JSON patch is too powerful). You can only use simple patch mechanisms, like key (with deep paths allowed) -> value object map. For that we have already partialClassToMongo
.
I'm getting TypeError: _parents.slice is not a function on master on mongoToClass.
I don't know what you are doing, but cheating in terms of not telling the true types is not the right way. Marshal needs types to work correctly. Also, without code and reproduction I can't help.
I essentially have this. All @Field()
is just doing schema.addProperty(meta.fieldName, f.any())
, the embedded arrays are doing schema.addProperty(meta.fieldName, f.array(Type))
, and embedded documents just schema.addProperty(meta.fieldName, f.any())
export class Product {
@Field()
sku: string;
@Field()
title: string;
}
export class Address {
@Field()
city: string;
@Field()
state: string;
}
export class Review {
@Field(() => Product)
product: Product;
@Field()
rating: number;
}
@Document({ repository: () => UserRepository })
export class User {
@Field() // @f.any()
_id: ObjectId;
@Field() // @f.any()
name: string;
@Field(() => Address) // @f.any()
address: Address;
@Field(() => [Review]) // @f.array(Review)
reviews: Review[] = [];
@Field() // @f.any()
createdAt: Date;
@Field() // @f.any()
isActive: boolean;
}
For BC sake, I was hoping any would just bypass validations, etc. Sort of how mongoose
"raw" option works. Then I can add types on top for better safety.
Here is what console.log
does on _parents
before the error:
console.log('_parents', _data, _options, _parents, _state);
_parents { product: { sku: '2', title: 'Frame' }, rating: 5 } [
User {
reviews: [],
_id: 5efd16f7c74aec107af813cc,
name: 'John',
address: Address { city: 'San Diego', state: 'CA' }
}
] ToClassState { onFullLoadCallbacks: [] } ToClassState { onFullLoadCallbacks: [] }
So it seems you're already using the master version. The new options stuff for grouping introduced obviously a bug, I've fixed in https://github.com/marcj/marshal.ts/commit/7c59dc99f365d1b0270db2f000275d4ecf76065e. Weird that the test suite hasn't catched that.
@marcj Yup, sweet. Yeah, I went back to latest release and most things are working. Just have to implement my version of parent mapping, field renaming, and object id string changes. So far working with minimal effort :)
It seems that functionality in this issue has been addressed and implemented. I'm going to close this issue. Feel free to reopen if anything else need to be discussed.
Hi Marc,
Thanks for this awesome lib! I have a feature request, I Thought I could use partialPlainToClass with @f.exclude but doesn't really cover my need.
I would like to filter all nested params based on some filter name. Example:
I need to convert this reader back into a plain object, but only containing some of its properties (and nested properties), based on the filter name. I would expect something like this:
There is a way to achieve this with the current api? If there's not, does this make sense to you?
I Want to use this to send json through an API, only sending the pertinent fields to each URL.