class-transformer icon indicating copy to clipboard operation
class-transformer copied to clipboard

Add support to AND and OR groups

Open lazarljubenovic opened this issue 6 years ago • 8 comments

First of all, thanks for the amazing lib. I love its potential! I'm fiddling with the groups functionality and I really like how it enables me to keep my "transformers" in on the class itself. When the model updates and I need to add another field, I write how that field will be visible to different "views" immediately instead of hunting down ten thousand transformer files.

Details and backstory

I'm trying to find a good pattern to transform data for sending an API response, but I'm finding limitations since the "groups" are doing the OR operator between them. This sort-of-a-feature-request and sort-of-a-question and sort-of-a-proposal at the same time.

For example, my User model has (at least) two "axes" on how we can categorize the response which includes the user of id 1.

  • How is user 1 related to me? (Is 1 actually me, are we friends on the platform or are we strangers?) This gives us three categories: me, friend and stranger.
  • Am I fetching this user as part of a list (eg. search results, less info) or a single user (full profile info)? This gives us two categories: list and single.

Notice how the first three categories cover the entire spectrum of possible users. Also, it's not possible that a user is in two of the three categories at the same time -- they are disjunctive. Same with the last two.

Problem

The problem is that I cannot say that a certain field is visible only when fetching my own info (me) in the single view (single). If I say @Expose({ groups: ['me', 'single'] }), that would mean me OR single, so fetching a stranger's info on the single view would include that field as well.

As the lib currently stands, to do what I want to do would mean having to create the Cartesian product of all categories. Instead of me, friend, stranger, list and single, I'd need to have me-single, me-list, friend-single, friend-list, stranger-single, stranger-list. The problem is that this list grows quickly and is very difficult to maintain.

Proposal 1

One way I see this possible is to introduce some sort of query builder for the AND/OR expressions.

@Expose({
  groups: and(or('me', 'friend'), 'single') // ['me-single', 'friend-single']
})

classToPlain(User, user, { groups: ['me', 'single'] }) // matches
classToPlain(User, user, { groups: ['friend', 'list'] }) // doesn't match (requires single)

But this is a bit wonky and not very scalable. I like the second idea much better.

Proposal 2

The other way would be to make groups a function instead. I think this is a more flexible approach and also probably much easier to implement in class-transformer itself. Just pass in an array of groups and let the function (which returns a boolean) decide if it's included or not.

@Expose({
  groups: groups => (groups.some(g => g == 'me') || groups.some(g => g == 'friend')) && groups.some(g => g == 'single'),
})

// usage same as previous
classToPlain(User, user, { groups: ['me', 'single'] }) // matches
classToPlain(User, user, { groups: ['friend', 'list'] }) // doesn't match (requires single)

Basically, the current implementation accepts groups as string[] and then just checks if there's an intersection:

https://github.com/typestack/class-transformer/blob/c3cd7f935adaee70c12d89db7327e51ce9c6679a/src/TransformOperationExecutor.ts#L401

My suggestion is that, if groups satisfies typeof groups == 'function', we just call this.options.groups(groups). I haven't tested it at all, but it seems like that's the only thing that needs to change... I think. I'll give it a shot soon.

If would be up to developer to define more complex utility functions to enable syntax like in Proposal 1.

lazarljubenovic avatar Nov 15 '18 21:11 lazarljubenovic

If would be up to developer to define more complex utility functions to enable syntax like in Proposal 1.

Indeed:

type Arg = string | ((xs: string[]) => boolean)

const _testAgainst = (groups: string[]) => (x: Arg) =>
  typeof x == 'string' ? groups.indexOf(x) > -1 : x(groups)
const and = (...xs: Arg[]) => (groups: string[]) => xs.every(_testAgainst(groups))
const or = (...xs: Arg[]) => (groups: string[]) => xs.some(_testAgainst(groups))

console.clear()

const q = and(or('me', 'friend'), 'single')
q(['me', 'single']) // true
q(['friend', 'single']) // true
q(['friend', 'list']) // false

Stackblitz demo here.

lazarljubenovic avatar Nov 15 '18 21:11 lazarljubenovic

thank you for writing this detailed proposal.

I like the first approach with the declarative functions more.

But this is only my opinion. Having a few helper functions wouldn't be to much work.

I think having and(), or(), not() would cover many of the cases.

MTschannett avatar Nov 16 '18 09:11 MTschannett

@MTschannett Thanks for the reply.

Functions from https://github.com/typestack/class-transformer/issues/203#issuecomment-439205289 show that the two approaches work together pretty well, in fact. We could include stuff from that comment into this library and then users could write the first approach while still having the flexibility benefit from the second one.

We could export the groupQueryBuilder (name TBD) like this from the lib:

// group-query-builder.ts
type Arg = string | ((xs: string[]) => boolean)

const _testAgainst = (groups: string[]) => (x: Arg) =>
  typeof x == 'string' ? groups.indexOf(x) > -1 : x(groups)
export const and = (...xs: Arg[]) => (groups: string[]) => xs.every(_testAgainst(groups))
export const or = (...xs: Arg[]) => (groups: string[]) => xs.some(_testAgainst(groups))
// index.ts (entry point)
import * as groupQueryBuilder from './group-query-builder'
export { groupQueryBuilder }

Now a user can use these functions:

import { groupQueryBuilder as q } from 'class-transformer'

class Entity {
  @Expose({ groups: q.or('foo', 'bar') })
  readonly id: string
}

The things below are outdated. They are the first WIP version; the polished code can be found in the comment below (https://github.com/typestack/class-transformer/issues/203#issuecomment-439350539).

What I'm experimenting right now is with creating these separate groups and using them in an easy manner. What I have is a helper function [1] which can be used as follows.

const g = createGroup({
  rel: {
    me: 'me',
    friend: 'friend',
    stranger: 'stranger',
  },
  view: {
    list: 'list',
    single: 'single',
  },
})

It just adds an additional property $all on every level, including the first one. Pretty much a shortcut for writing this:

import { groupQueryBuilder as q } from 'class-transformer'

const g = {
  rel: {
    me: 'me',
    friend: 'friend',
    stranger: 'stranger',
    $all: q.or('me', 'friend', 'stranger'),
  },
  view: {
    list: 'list',
    single: 'single',
    $all: q.or('list', 'single'),
  },
  $all: q.or('me', 'friend', 'stranger', 'list', 'single')
}

Meaning that now I can write this:

@Expose({ groups: g.$all })
readonly id: string

@Expose({ groups: q.and(g.rel.me, g.view.single) })
sentFriendRequests: FriendRequest[]

I still haven't given a real go at checking more use-cases throughout my project, but for now I feel like it's a pretty expressive and easy way to declare what is visible to which groups.

[1] Helper function
import * as q from 'q'

type GroupSpec = Record<string, string | Record<string, string>>

type Group<Spec extends GroupSpec> =
  Spec &
  {
    [axis in keyof Spec]: Spec[axis] & { $all: (groups: string[]) => boolean }
  } &
  {
    $all: (groups: string[]) => boolean,
  }

export function createGroup <T extends GroupSpec> (group: T) {
  const _group = group as any
  const finalResult = Object.keys(_group).reduce((result: any, axisName: any) => {
    console.log(_group, axisName, _group[axisName])
    return {
      ...(result as any),
      [axisName]: {
        ..._group[axisName],
        $all: q.or(Object.keys(_group[axisName]).map(key => _group[axisName][key]) as any),
      },
    }
  }, {}) as Group<T>
  const all = Object.keys(_group)
    .map(axisName => _group[axisName].$all)
    .reduce((acc, curr) => q.or(curr, acc))
  return Object.assign(finalResult, { $all: all })
}

lazarljubenovic avatar Nov 16 '18 09:11 lazarljubenovic

Alright, here's a Blitz of the polished version of the stuff from the comment above. The three functions in the index.ts file can just be given directly to @Expose({ groups: xxx }).

For completeness, code from the Blitz:

class-transformer/create-group.ts
import * as lb from './logic-builder'

type GroupSpec = Record<string, Record<string, string>>

type Group<Spec extends GroupSpec> =
  Spec &
  {
    $all: (groups: string[]) => boolean,
  } &
  {
    [axis in keyof Spec]: Spec[axis] & { $all: (groups: string[]) => boolean }
  }

function wrapKeysInArray <T extends Record<string, string>>(object: T): { [key in keyof T]: Array<T[key]> } {
  const result: Partial<{ [key in keyof T]: Array<T[key]> }> = {}
  const keys = Object.keys(object)
  for (const key of keys) {
    const value = object[key]
    result[key] = [value]
  }
  return result as { [key in keyof T]: Array<T[key]> }
}

export default function createGroup<T extends GroupSpec>(spec: T): Group<T> {
  const finalResult: any = {}
  const axisNames = Object.keys(spec)

  // create $all and change string to [string] in each axis
  for (const axisName of axisNames) {
    const singleGroupSpec = spec[axisName]
    const groupKeys = Object.keys(spec[axisName])
    const groupValues = groupKeys.map(key => spec[axisName][key])
    const singleGroup = {
      ...singleGroupSpec,
      $all: lb.or(...groupValues),
    }
    finalResult[axisName] = singleGroup
  }

  // add a global $all
  const allOrs = Object.keys(finalResult).map(axisName => finalResult[axisName].$all)
  finalResult.$all = lb.or(...allOrs)

  return finalResult as Group<T>
}
class-transformer/logic-builder.ts
type Arg = string | ((xs: string[]) => boolean)

const _testAgainst = (groups: string[]) => (x: Arg) =>
  typeof x == 'string'
    ? groups.indexOf(x) > -1
    : x(groups)

const and = (...xs: Arg[]) => (groups: string[]) => xs.every(_testAgainst(groups))
const or = (...xs: Arg[]) => (groups: string[]) => xs.some(_testAgainst(groups))

export {
  and,
  or,
}
class-transformer/index.ts
import createGroup from './create-group'
import * as lb from './logic-builder'

export {
  createGroup,
  lb,
}
index.ts (usage)
import { createGroup, lb } from './class-transformer'

const g = createGroup({
  view: {
    list: 'list',
    single: 'single',
  },
  rel: {
    me: 'me',
    friend: 'friend',
    stranger: 'stranger',
  },
})

console.clear()
console.log(g)

// always expose
const everyone = g.$all

// expose when fetching the full profile, for the single view
const single = lb.and(g.view.single, g.rel.$all)

// show only when fetcing my own profile in the single view
const singleMe = lb.and(g.view.single, g.rel.me)

// Testing
console.log('Everyone')
console.log(everyone(['me']))
console.log(everyone(['me', 'list']))
console.log(everyone(['me', 'single']))
console.log(everyone(['friend', 'single']))

console.log('Single')
console.log(single(['me']))
console.log(single(['me', 'list']))
console.log(single(['me', 'single']))
console.log(single(['friend', 'single']))

console.log('Single, me')
console.log(singleMe(['me']))
console.log(singleMe(['me', 'list']))
console.log(singleMe(['me', 'single']))
console.log(singleMe(['friend', 'single']))
Output
Everyone
  true
  true
  true
  true
Single
  false
  false
  true
  true
Single, me
  false
  false
  true
  false

lazarljubenovic avatar Nov 16 '18 10:11 lazarljubenovic

Thank you for the work you have put into this.

On the first glance it seems quite easy to have show complex dependencies between objects and the functionality they show.

But I'd like to hear what @cojack or @NoNameProvided think about this

MTschannett avatar Nov 29 '18 21:11 MTschannett

Seems like #240 would also benefit from a predicate in the exclude options as well.

kaleb avatar Mar 09 '19 20:03 kaleb

Any news? 😅

NarHakobyan avatar Jan 03 '23 20:01 NarHakobyan

Does anyone work on this?

RadekKpc avatar Jul 03 '23 11:07 RadekKpc