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

Alternative model definition API #592

Closed
s-panferov opened this issue Jan 10, 2018 · 6 comments
Closed

Alternative model definition API #592

s-panferov opened this issue Jan 10, 2018 · 6 comments

Comments

@s-panferov
Copy link

s-panferov commented Jan 10, 2018

I want to share my small research for those who interested. It appeared very hard to me to use default model definition API, because:

  1. I faced with TypeScript's recursive-definition type errors, when you cannot use cycles in types.
  2. I don't like the level of nesting and a lot of syntactical distractions.
  3. yield expression returns cannot be typed in TypeScript, so await syntax is more preferable for async actions.
  4. Cannot model generic types.
  5. Always mistype self and this

So for my project I decided to go with decorator+transformations approach. I wrote simple @action, @field, @view and @volatile decorators and a createModel function:

Click to expand the implementation
type FieldDecorator = (type: IType<any, any>) => PropertyDecorator

interface MSTIntrospection {
	fields: {
		[key: string]: IType<any, any>
	}
	views: {
		[key: string]: string | symbol
	}
	actions: {
		[key: string]: string | symbol
	}
	volatile: {
		[key: string]: string | symbol
	}
}

const introSymbol = Symbol.for('mstIntrospection')

function ensureMSTIntrospection(object: any): MSTIntrospection {
	if (object[introSymbol]) {
		return object[introSymbol]
	} else {
		const intro = {
			fields: {},
			views: {},
			actions: {},
			volatile: {},
		}
		object[introSymbol] = intro
		return intro
	}
}

export const field: FieldDecorator = type => {
	return (target, property) => {
		const intro = ensureMSTIntrospection(target)
		intro.fields[property.toString()] = type
	}
}

export const view: PropertyDecorator = (target, property) => {
	const intro = ensureMSTIntrospection(target)
	intro.views[property.toString()] = property
}

export const action: PropertyDecorator = (target, property) => {
	const intro = ensureMSTIntrospection(target)
	intro.actions[property.toString()] = property
}

export const volatile: PropertyDecorator = (target, property) => {
	const intro = ensureMSTIntrospection(target)
	intro.volatile[property.toString()] = property
}

export function createModel<T extends { Snapshot?: any }>(Type: {
	new (): T
}): IModelType<T['Snapshot'], T> {
	const intro = ensureMSTIntrospection(Type.prototype)
	const instance = new Type()

	const fields: { [key: string]: any } = {}
	Object.keys(intro.fields).forEach(key => {
		if (instance.hasOwnProperty(key)) {
			const defaultValue = (instance as any)[key]
			fields[key] = types.optional(intro.fields[key], defaultValue)
		} else {
			fields[key] = intro.fields[key]
		}
	})

	const myModel = types
		.model(Type.name, fields)
		.extend(self => {
			const priv = Object.getOwnPropertyDescriptor(Type, '_')
			if (priv && priv.get) {
				self._ = priv.get()
			}

			const views: { [key: string]: any } = {}

			Object.keys(intro.views).forEach(key => {
				const desc = Object.getOwnPropertyDescriptor(Type.prototype, key)!
				if (desc.value) {
					views[key] = desc.value.bind(self)
				} else if (desc.get) {
					Object.defineProperty(views, key, {
						get: desc.get.bind(self),
					})
				}
			})

			const actions: { [key: string]: any } = {}
			Object.keys(intro.actions).forEach(key => {
				const desc = Object.getOwnPropertyDescriptor(Type.prototype, key)!
				const func = desc.value
				if (func) {
					if (
						func.prototype &&
						func.prototype.toString() === '[object Generator]'
					) {
						actions[key] = flow(func.bind(self))
					} else {
						actions[key] = func.bind(self)
					}
				}
			})

			return {
				views,
				actions,
			}
		})
		.volatile(_self => {
			const volatile: { [key: string]: any } = {}
			const instance = new Type()
			Object.keys(intro.volatile).forEach(key => {
				const defaultValue = (instance as any)[key]
				volatile[key] = defaultValue
			})

			return volatile
		}) as any

	myModel

	return myModel
}

The code above can be used to write an API like this:
class NoteModel {
	Snapshot: {
		_id?: string
		_rev?: string
		type?: 'note'
		text?: string
		tasks?: Task.Snapshot[]
		createdAt?: number
		updatedAt?: number
		assets?: Asset.Snapshot[]
	}

	@field(t.optional(t.identifier(t.string), uuid))
	_id: string

	@field(t.optional(t.union(t.string, t.undefined), undefined))
	_rev?: string

	@field(t.optional(t.literal('note' as 'note'), 'note'))
	type: 'note'

	@field(t.string)
	text =  '' // Automatically wrap with `t.optional`

	@field(t.optional(t.array(Task), []))
	tasks: Task[]

	@field(t.optional(t.union(t.Date, t.undefined), undefined))
	createdAt?: Date

	@field(t.optional(t.union(t.Date, t.undefined), undefined))
	updatedAt?: Date

	@field(t.optional(t.array(Asset), []))
	assets: Asset[]

	@view
	validate(): boolean {
		return this.text.length > 0 && !/^[\s\n]/.test(this.text)
	}

	@action
	edit(text: string): void {
		this.text = text
	}

	@action
	async save(db: PouchDB.Database): Promise<any> {
		if (!this.createdAt) {
			this.createdAt = new Date()
		}

		this.updatedAt = new Date()
		const res: PouchDB.Core.Response = await db.put(getSnapshot(this))
		this._rev = res.rev
	}
}

export type Note = NoteModel & IStateTreeNode
export const Note = createModel(NoteModel)

createModel automatically does all the transformations and binding and even supports a private state:

class MyModelWithPrivateState {
  private get _() { return { name: 'Some private internal state'} }
  @view
  accessInternal {
    return this._.name
  }
}

The biggest challenge is to remap await to yield calls. This is achieved with a simple Babel@7 plugin, which is invoked before typescript:

Click to expand webpack.config.json
{
	test: /\.tsx?$/,
	exclude: /(node_modules|bower_components)/,
	loaders: [
		{
			loader: 'awesome-typescript-loader',
		},
		{
			loader: 'babel-loader',
			options: {
				plugins: [
					require('babel-plugin-syntax-jsx'),
					require('babel-plugin-syntax-class-properties'),
					require('babel-plugin-syntax-decorators'),
					require('babel-plugin-syntax-typescript'),
					require('babel-plugin-syntax-object-rest-spread'),
					require('./transformer'),
				],
			},
		},
	],
},

Click to expand transformer.ts
import { Visitor } from 'babel-core'
import * as t from 'babel-types'

const awaitVisitor: Visitor = {
	AwaitExpression(path) {
		const argument = path.get('argument')

		if (path.parentPath.isYieldExpression()) {
			path.replaceWith(argument.node)
			return
		}

		path.replaceWith(t.yieldExpression(argument.node as any))
	},
}

const visitor: Visitor = {
	ClassMethod(method) {
		if (method.node.decorators && method.node.decorators.length) {
			const action = method.node.decorators.find(dec => {
				if (dec.expression.type === 'Identifier') {
					return dec.expression.name === 'action'
				}
			})

			if (action && method.node.async) {
				const source = method.getSource()
				method.node.async = false
				method.node.generator = true
				method.traverse(awaitVisitor)
			}
		}
	},
}

module.exports = {
	visitor,
}

It's better to run this transformation after TypeScript, but unfortunately there is no way to disable decorator emit right now in TypeScript.

Cons of this approach:

  1. You need a babel plugin to make it work.
  2. You need to write a Snapshot type by hand.
  3. Current TypeScript version does not support generic decorators, so you cannot check that MST type matches a corresponding property type. But they are written close in the code, so it's hard to make a mistake.
  4. You have to remember to write these decorators.

Pros of this approach:

  1. Simple one-level class syntax with this support
  2. Can model generic types
  3. Async actions are properly typed using await keyword instead of yield
  4. You can build recursive type definitions.
  5. Automatically wrap with t.optional using a native property initialization API.
  6. Less work for TypeScript to process definitions.
  7. Potential to make class inheritance work in the future.

I share this not because I want to propose to change the default API, but maybe to give an alternative way to work with mobx-state-tree for those who experience the same problems as me. And maybe this approach can be adopted somehow in the library.

@s-panferov s-panferov changed the title Alternative model definition API An alternative model definition API Jan 10, 2018
@s-panferov s-panferov changed the title An alternative model definition API Alternative model definition API Jan 10, 2018
@KaySackey
Copy link

KaySackey commented Jan 12, 2018

I know its really unlikely that we'll change the default API, but since lots of people are coming up with alternatives (I remember MST-Classy) is one... perhaps we can document all the alternative styles somewhere with MST being the base-project?

@s-panferov
Copy link
Author

I'll create a repository with this code.

@mweststrate
Copy link
Member

Hey @s-panferov!

As suggested above we cannot make this the default syntax in MST (it has seemingly no full feature parity, and would complicate the build setup of any consumer). So shipping as separate package (or join forces with mst-classy) would be encouraged. Make sure to mention the link here: mobxjs/awesome-mobx#39

Thanks

@steve8708
Copy link
Contributor

I'm very interested in this @s-panferov if you set it up please let me know!

@JonDum
Copy link

JonDum commented Nov 21, 2018

@steve8708 Here's my ES6 version in case you or someone else who comes across this thread is still interested. Couldn't find a working version from @s-panferov anywhere and I don't use typescript.

Note that I added prototype walking so you can extend from other classes, use a decorator to apply it, auto-optional/maybe every field, and I auto-unprotect every instance in an afterCreate() to make it more like vanilla mobx (I really don't see the value in having to make an action for every single property — it's just boilerplate and anti-pattern — but that can be removed easily if desired).

import {types, flow, unprotect} from 'mobx-state-tree'

const introSymbol = Symbol.for('mstIntrospection')

function ensureMSTIntrospection(object) {
	if (object[introSymbol]) {
		return object[introSymbol]
	} else {
		const intro = {
			fields: {},
			views: {},
			actions: {},
			volatile: {},
		}
		object[introSymbol] = intro
		return intro
	}
}

export const field = type => {
	return (target, property) => {
		const intro = ensureMSTIntrospection(target)
		intro.fields[property.toString()] = type
	}
}

export const computed = (target, property) => {
	const intro = ensureMSTIntrospection(target)
	intro.views[property.toString()] = property
}

export const action = (target, property) => {
	const intro = ensureMSTIntrospection(target)
	intro.actions[property.toString()] = property
}

export const volatile = (target, property) => {
	const intro = ensureMSTIntrospection(target)
	intro.volatile[property.toString()] = property
}

const getPropDescriptor = (obj, property) => {
	let supe, desc = Object.getOwnPropertyDescriptor(obj.prototype, property)
	while(!desc && (supe = Object.getPrototypeOf(obj))) {
		desc = getPropDescriptor(supe, property)
	}
	return desc
}

export function mst(Type) {
	const intro = ensureMSTIntrospection(Type.prototype)
	const instance = new Type()

	const fields = {}
	Object.keys(intro.fields).forEach(key => {
		if (instance.hasOwnProperty(key) && typeof instance[key] !== 'undefined') {
			const defaultValue = instance[key]
			fields[key] = types.optional(intro.fields[key], defaultValue)
		} else {
			fields[key] = types.maybe(intro.fields[key])
		}
	})

	const model = types
		.model(Type.name, fields)
		.extend(self => {

			const views = {}
			Object.keys(intro.views).forEach(key => {
				const desc = getPropDescriptor(Type, key)
				if (desc && desc.value) {
					views[key] = desc.value.bind(self)
				} else if (desc.get) {
					Object.defineProperty(views, key, {
						get: desc.get.bind(self),
					})
				}
			})

			const actions = {}
			Object.keys(intro.actions).forEach(key => {
				const desc = getPropDescriptor(Type, key)
				if (desc && desc.value) {
					const func = desc.value
					if (
						func.prototype &&
						func.prototype.toString() === '[object Generator]'
					) {
						actions[key] = flow(func.bind(self))
					} else {
						actions[key] = func.bind(self)
					}
				}
			})

			actions.afterCreate = function() {
				 unprotect(self)
			}

			return {
				views,
				actions,
			}
		})
		.volatile(_self => {
			const volatile = {}
			const instance = new Type()
			Object.keys(intro.volatile).forEach(key => {
				const defaultValue = instance[key]
				volatile[key] = defaultValue
			})

			return volatile
		})

	return model
}

And then you use it almost like vanilla mobx:

import {mst, computed, field, action, volatile} from 'mst'

@mst
export class Crew extends Model {

	@field(types.string) name
	@field(types.string) email
	@field(types.string) phone
	@field(types.string) position

        @field(types.array(Event)) events = []

       	@computed
	get total() {
		return this.things.reduce((a, c) => a + c, 0)
	}
   
       @action doStuff() {
             
        }

}

@farwayer
Copy link

Hi! I just released the mst-decorators library to define class-based models. I used this library for 1.5 years for dozens of projects but it may still contain some bugs. And there is no TS defs yet. Feel free to open issues/PRs.

Some features:

  • simple syntax without need to use extra helpers etc, just decorators
  • es6 extending
  • access to instance via this
  • @view, @action, @flow, @volatile decorators
  • model class is decorator so it can be used in another model (@Location)
  • late definition for recursive models (@late(() => ref(Category)) topCategory)
  • preProcessSnapshot/postProcessSnapshot as static class methods
  • can specify onPatch/onSnapshot/onAction just in class
  • lifecycle hook actions, composing and getEnv() works as well
  • several extra decorators: @jsonDate, @setter
  • result of decorator function is decorator. Feel power in constructing types!
@model class User {}
const Author = maybe(ref(User))

@model class Message {
  @Author author
}

Several examples:

import {
  model, view, action, flow, ref, bool, array, map, maybe, id, str, jsonDate,
} from 'mst-decorators'

@model class BaseUser {
  @id id
  @str username
  @str password
}

@model class User extends BaseUser {
  @maybe(str) phone
  @maybe(str) firstName
  @maybe(str) lastName
  
  @view get fullName() {
    if (!this.firstName && !this.lastName) return
    if (!this.lastName) return this.firstName
    if (!this.firstName) return this.lastName
    return `${this.firstName} ${this.lastName}`
  }

  @action setPhone(phone) {
    this.phone = phone
  }
}

@model class Location {
  @num long
  @num lat
}

@model class Message {
  @id id
  @ref(User) sender
  @str text
  @jsonDate date
  @bool unread
  @Location location

  static preProcessSnapshot(snap) {
    //...
  }
  static postProcessSnapshot(snap) {
    //...
  }
  
  onPatch(patch, reversePatch) {
    //...
  }
  onSnapshot(snapshot) {
    //...
  }
  onAction(call) {
    //...
  }
}

@model class Chat {
  @id id
  @array(Message) messages
  @map(User) users

  @action afterCreate() {
    this.fetchMessages()
  }

  @flow fetchMessages = function* () {
    this.messages = yield Api.fetchMessages()
  }
}

const chat = Chat.create({
  id: '1',
})

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

No branches or pull requests

6 participants