RXJS: Create and chain a dynamic array of filters

Viewed 437

I'm trying to create a service that can dynamically filter a stream of Record<string, unkonwn> by it's keys. The idea is I can call like an "addFilter" on the service and pass an observable with a filter which is then stored in an array of observables in the service. Then when the observable of Records notifies in the service, it should enumerate all of the filtered observables in the array and remove them from the stream.

I'm probably WAY off base here, but this is where I am at right now, just creating the actual chain of filters.

  chainFilters(filters: Observable<Record<string, unknown>>[]) {
    return (source$: Observable<Record<string, unknown>>) => {
      var observables = filters.reduce(
        (
          acc: Observable<Record<string, unknown>>[],
          value: Observable<Record<string, unknown>>
        ) => {
          return acc.concat(value);
        }, []);
      return source$.pipe(???);
    }
  }

I thought about returning concat(observables) in this case but it says concat is deprecated. No clue where to go from here.

The best example I could find of something remotely similar is here but that doesn't work with RxJS in angular, I'm a little confused about how to translate that.

EDIT: Project is using RxJS 6.5.5

At the request of @maxime1992, am going to try to explain the problem a little more to help with understanding...

So I have a stream of Record<string, unknown> coming from a dynamic array of form fields, form groups, and form arrays, all generated with JSONSchema using Formly that combine to build a multi step wizard of sorts. That in and of itself is not important to except to say that the forms themselves cannot be changed, they just emit the Record<string, unknown> objects. Nested form groups may emit records that contain multiple child "Records."

The problem comes in that although most of those "Records" are valid against the original JSON schema from the db, others are added virtually based on a json config that is merged with the schema before generating the form. Those wont validate against the json schema in the database, and need to get 'plucked' (for lack of a better word), OUT of of the stream before the remainder of the stream is sent to the server to save. So basically there would be ONE subscriber for each plucked record, which would be a separate service that would inject the main service where the stream is coming from, and call an "addFilter" function on that service, with a filter defined by the subscriber. This would put a filter into the array of filters on the main service. Each filter removes the record being filtered from the main stream to used by the subscribing service, and lets the others go through unimpeded.

I am not needing to know how to do all the services, I just am having trouble figuring out how to structure the filter, in a way that can be chained via the array of filters, to remove those records from the main stream.

I hope that helps describe the problem a little more...

1 Answers

Still not 100% sure what you're trying to achieve exactly but I'm going to give it a go and you can give feedback if that's what you expect. Maybe it'll be easier to discuss around some code.

First, let's talk API without any details on how the MainStreamService is implemented behind the scene. Does that look ok to you?

Assuming that the initial data is:

{
  "a": 1,
  "b": 2,
  "c": 3,
  "d": 4,
  "f": 5
}

And we have the following code:

const mainStreamService = new MainStreamService();

mainStreamService.mainDataFiltered$
  .pipe(
    map(x => JSON.stringify(x, null, 2)),
    tap(console.log)
  )
  .subscribe();

const dynamicFilter$$ = new Subject<Record<string, unknown>>();

mainStreamService.addFilter(dynamicFilter$$);

dynamicFilter$$.next({ b: null, c: null, d: null });

It outputs 2 results:

{
  "a": 1,
  "b": 2,
  "c": 3,
  "d": 4,
  "f": 5
}

(the initial data)

{
  "a": 1,
  "f": 5
}

(the data after the filter was applied)

In the hope that's pretty much what you're looking for, here the implementation I've got:

import {
  of,
  map,
  Observable,
  Subject,
  combineLatest,
  BehaviorSubject
} from 'rxjs';
import { mergeAll, scan, shareReplay, tap } from 'rxjs/operators';

class MainStreamService {
  // this is simply a mock, push the data you want in here
  public mainData$: Observable<Record<string, unknown>> = of({
    a: 1,
    b: 2,
    c: 3,
    d: 4,
    f: 5
  });

  // a behavior subject will let you get the last emitted value
  // if you subscribe to it later on, which is what we want here
  // important thing to note, it's a behavior subject of observable...
  // so when we start modifying that stream (using `pipe`, we'll consider it to be an observable... of observables!)
  // we'll see how to deal with that in second
  private filters$$: BehaviorSubject<
    Observable<Record<string, unknown>>
  > = new BehaviorSubject(of({}));

  // using the filters defined above, we want to go from an observable of observables... to an observable
  // whenever you need to do that, you have to think "higher order operator"
  // a higher order operator will let you extract an observable of observable, into an observable
  // it's not as complicated as it sounds and if you're familiar with
  // the observables such as `switchMap`, `mergeMap` (or `flatMap`), `concatMap`, `exhaustMap`, ...
  // then you won't struggle to understand that concept because it's the same thing except that it'll extract
  // the values from the inner observable and put them back into the main observable
  // the operators that are available to you are [`switchAll`][1], [`mergeAll`][1], [`concatAll`][1], [`exhaustAll`][1], ...
  // and as you guessed it, they have the same behavior as the ones you know already with the name ending by `map` instead of `all`
  private excludedKeys$: Observable<Record<string, true>> = this.filters$$.pipe(
    // here I've used merge all to make sure that we listen to all of them in parallel otherwise if we used `switchAll` for example, we would unsubscribe from the old filter observable before subscribing to the new one
    mergeAll(),
    // finally, scan is a life savior! If you're used to the `reduce` function in Javascript it should click straight away. If not, here's the deal: 
    // `scan` will let you accumulate values over time and you have to specify an initial value
    // when working with observables, you want to have immutable data, always
    // therefore, I start of by passing the second argument of the function a new object `{}`
    // whenever we receive a new filter (object), we read the keys
    // and I do a `reduce` on the array (pure JS here, not reduce from rxjs!) to loop over all the keys and assign a new value for each of those: `true`
    // note on this: I could use an array here but as we're only interested in knowing if a key is black listed, we're better off putting them into 
    // an array that we'd have to iterate on all the time and we rather use an equivalent of a `Set`... Except that `Set` isn't immutable. So I'm using an object to represent a dictionary
    scan((acc, obj) => {
      const keys = Object.keys(obj);

      return keys.reduce(
        (a, key) => {
          a[key] = true;
          return a;
        },
        { ...acc }
      );
    }, {})
  );

  // with the `combineLatest` here we make sure to run the rest of the observable whenever either of those change
  public mainDataFiltered$: Observable<Record<string, unknown>> = combineLatest(
    [this.mainData$, this.excludedKeys$]
  ).pipe(
    map(([mainData, excludedKeys]) =>
      // we take the main data object and list all it's keys
      // we loop onto them using reduce and only add them into
      // the new object (passed as second argument of the reduce, to respect immutability)
      // IF they're not part of the excluded keys
      Object.keys(mainData).reduce((acc, key) => {
        if (!excludedKeys[key]) {
          acc[key] = mainData[key];
        }
        return acc;
      }, {})
    ),
    // finally, as we could subscribe to this variable from various locations at the same time and we don't want all that logic being run as many times as we've got subscribers, we use `shareReplay`
    // it lets us subscribe only once to it IF there's at least 1 subscription (`refCount: true`). Otherwise it'll complete. And if there's a new subscriber, we want the new subscriber to get the last value that was emitted before he joined so we do: `bufferSize: 1`
    shareReplay({ refCount: true, bufferSize: 1 })
  );

  public addFilter(filter$: Observable<Record<string, unknown>>): void {
    this.filters$$.next(filter$);
  }
}

You can have a live go at it on Stackblitz: https://stackblitz.com/edit/rxjs-doqaxn?devtoolsheight=60&file=index.ts

If this is not what you're looking for, please edit your post with the kind of API you'd like to have and I can probably help with the implementation behind the scenes.

Related