0

I'm joining multiple observables in one global joined$ observable using switchMap and combineLatest

this is the component TS file:

export class ProgressComponent implements OnInit{

    user: User;
    joined$: Observable<any>;

    constructor(
        protected tasksService: TasksService,
        protected courseService: CoursesService,
        protected fieldService: FieldsService,
        protected sessionService: SessionsService,
        protected applicationService: ApplicationForSessionsService,
        protected TaskdatesService: SessionTaskDatesService,
        protected router: Router,
        private userStore: UserStore)
    {}

    ngOnInit(): void {
        this.user = this.userStore.getUser();
        this.joined$ = this.applicationService.list(1, 10, undefined, this.user.id)
            .pipe(
                switchMap(applications => {
                    const courseSessionIds = uniq(applications.map(application => application.courseSessionId))

                    return combineLatest(
                        of(applications),
                        combineLatest(
                            courseSessionIds.map(courseSessionId => this.sessionService.get(courseSessionId).pipe(
                                switchMap(session => {
                                    const courseId = session.courseId
                                    return combineLatest(
                                        of(session),
                                        combineLatest(
                                            this.courseService.get(courseId).pipe(
                                                switchMap(course => {
                                                    const fieldId = course.fieldId

                                                    return combineLatest(
                                                        of(course),
                                                        combineLatest(
                                                            this.fieldService.get(fieldId).pipe(
                                                                map (field => field)
                                                            )
                                                        )
                                                    )
                                                }),
                                                map(([course, field]) => {
                                                    return {...course, field: field.find(f => f.id == course.fieldId)}
                                                })
                                            )
                                        ),
                                                                         
                                    )
                                }),
                                map(([session, course]) =>  {
                                    return {
                                        ...session, 
                                        course: course.find(c => c.id === session.courseId)
                                    }
                                }),
                                switchMap( session => {
                                    const sessionId = session.id;

                                    return combineLatest(
                                        of(session),
                                        combineLatest(
                                            this.TaskdatesService.getBySessionId(sessionId).pipe(
                                                switchMap(dates => {
                                                    const taskDatesIds = uniq(dates.map(dt => dt.taskId));

                                                    return combineLatest(
                                                        of(dates),
                                                        combineLatest(
                                                            taskDatesIds.map(taskDateId => this.tasksService.get(taskDateId).pipe(
                                                                map(task => task)
                                                            ))
                                                        )
                                                    )
                                                }),
                                                map(([dates, task]) => {
                                                    return dates.map(date => {
                                                        return {...date, task: task.find(t => t.id === date.taskId)}
                                                    })
                                                
                                                })
                                            )
                                        )
                                    )
                                }),
                                map(([session, dates]) => {
                                    return {
                                        ...session,
                                        dates: dates.map(date => date.find(d => d.sessionId === session.id))
                                    }
                                })
                            ))
                        )
                    )
                }),
                map(([applications, session]) => {
                    return applications.map(app => {
                        return {
                            ...app,
                            session: session.find(s => s.id === app.courseSessionId)
                        }
                    })
                })
            );
    }
}

And here is the HTML template file

    <ng-container *ngIf="joined$ | async; else loading; let joined">

    <div *ngFor="let application of joined">
        <div class="current-application">
            <nb-card>
                <nb-card-header>
                    <h4>{{application.session.course.name}}</h4>
                    <small><i>{{application.session.course.field.name}}</i></small>
                </nb-card-header>
                <nb-card-body>
                    <p><b>id: </b>{{application.id}}</p>
                    <p><b>applicationDate: </b>{{application.applicationDate}}</p>
                    <p><b>acceptedDate: </b>{{application.acceptedDate}}</p>
                    <hr>
                    <p><b>session Id: </b>{{application.session.id}}</p>
                    <p><b>session capacity: </b>{{application.session.capacity}}</p>
                    <p><b>session startDate: </b>{{application.session.startDate}}</p>
                    <p><b>session endDate: </b>{{application.session.endDate}}</p>
                    <hr>
                    <p><b>Course Id: </b>{{application.session.course.id}}</p>
                    <p><b>Course Id: </b>{{application.session.course.id}}</p>
                </nb-card-body>
            </nb-card>
        </div>
    </div>

</ng-container>

<ng-template #loading>
    <p>Loding ...</p>
</ng-template>

Edit: After debugging I found that the error occurs when Dates array is empty, so the solution consists of applying a test for the length of the dates array. The problem is when I try to make a condition I got the following error

Argument of type '(dates: SessionTaskDate[]) => void' is not assignable to parameter of type '(value: SessionTaskDate[], index: number) => ObservableInput'. Type 'void' is not assignable to type 'ObservableInput'.

triggered in the following:

switchMap(dates =>{
        if(dates.length > 0){
            dates.map(date => this.augmentDateWithTask(date))
        }
    })
Ilyas Ghomrani
  • 408
  • 6
  • 26
  • 2
    this is way too much code. At first glance: I don't think you should use switchmap inside a combinelatest (use it after the combineLates, and you don't have to do of(session) when your session value is already available, just to name a few problems – user3791775 Aug 31 '20 at 21:38
  • 2
    This code is very complicated and hard to follow. I'd suggest breaking it up into smaller pieces. If your template is always stuck on loading, then it's because `joined$` never emits a value. When using `combineLatest`, it will not emit any values until all of its source observables emit, so that might be your problem. – BizzyBob Aug 31 '20 at 21:43
  • 1
    You shouldn't need to use `of` and `combineLatest`. They are both functions that generate an observable. `combineLatest` is used when you have 2 or more source observables. In your case, it seems that you only have a single source, `applicationService.list()`. So it seems you don't really need `combineLatest` at all. – BizzyBob Aug 31 '20 at 21:47
  • I followed this [tutorial](https://medium.com/@joaqcid/how-to-inner-join-data-from-multiple-collections-on-angular-firebase-bfd04f6b36b7) I just need to inner join those entities. – Ilyas Ghomrani Sep 01 '20 at 00:24

2 Answers2

2

If I understand right, you have a series of applications which are notified by the Observable returned by this.applicationService.list.

Then each application has a courseSessionId which you can use to fetch course session details via this.sessionService.get method.

Then each session has a courseId which you can use to fetch course details via this.courseService.get.

Then each course has a fieldId which you can use to fetch field details via this.fieldService.get.

Now you should have an array of sessions containing also all details of the course they refer to.

Then, for each session, you need to fetch the dates via this.TaskdatesService.getBySessionId.

Dates seems to be objects containing a taskDatesId. You collect all taskDatesIds and fetch task details via this.tasksService.get.

Now that you have all dates and all tasks, for each date you create a new object with date properties and its related task.

Then you go back to the session you create a new object with all session properties and its related dates.

Now you have an object with all session details, all details of the course it refers to and all the details of the dates it has.

The last step is to create, for each application, a new object containing all properties of the application plus all properties of the "augmented" session object just created.

Quite some logic.

So, again, if this is the correct understanding, I would approach the problem from the most inner requests outwards.

The first inner request is the one that, starting from a course returns an Observable which emits an object with all course properties and the field details (let's call this object an augmentedCourse). This could be performed by a method like this

augmentCourseWithField(course) {
  return this.fieldService.get(course.fieldId).pipe(
    map (field => {
      return {...course, field}
    })
  )
}

Then I would make a step outwards and would create a method that, starting from a courseId, returns an Observable which emits an augmentedCourse, i.e. an object with all the course and the field details.

fetchAugmentedCourse(courseId) {
  return this.courseService.get(courseId).pipe(
     switchMap(course => this.augmentCourseWithField(course))
  )
}

Now let's do a further step outwards, and create a method that, starting from a session, returns an object with all properties of the session plus the properties of the augmentedCourse the session refers to. Let's call this object augmentedSession.

augmentSessionWithCourse(session) {
  return this.fetchAugmentedCourse(session.courseId).pipe(
    map(course => {
      return {...session, course}
    })
  )
}

Now one more step outwards. We want to fetch an augmentedSession starting from a courseSessionId.

fetchAugmentedSession(courseSessionId) {
  return this.sessionService.get(courseSessionId).pipe(
     switchMap(session => this.augmentSessionWithCourse(session))
  )
}

So, what did we achieve so far? We are able to create an Observable which emits an augmentedSession starting from a courseSessionId.

At the outermost level though we have a list of applications, each of which contains a courseSessionId. Different applications can share the same course and so have the same courseSessionId. Therefore it makes sense to fetch all applications, create a list of unique courseSessionIds, use such list to fetch all the courses and then assign to each application its course. In this way we avoid querying the back end more than once for the same course.

This can be acheived like this

fetchAugmentedApplications(user) {
  return this.applicationService.list(1, 10, undefined, user.id).pipe(
    switchMap(applications => {
      const courseSessionIds = uniq(applications.map(application => application.courseSessionId));
      // create an array of Observables, each one will emit an augmentedSession
      const augSessObs = courseSessionIds.map(sId => fetchAugmentedSession(sId))
      // we can use combineLatest instead of forkJoin, but I prefer forkJoin
      return forkJoin(augSessObs).pipe(
        map(augmentedSessions => {
          return applications.map(app => {
            const appSession = augmentedSessions.find(s => s.id === app.courseSessionId);
            return {...app, session: appSession}
          });
        })
      )
    })
  )
}

With a similar style you should be able to add also dates details to your sessions. Also in this case we start with innermost operation, which is to retrieve an array of tasks given an array of taskIds via his.tasksService.get.

The code would look like this

fetchTasks(taskIds) {
   taskObs = taskIds.map(tId => this.tasksService.get(tId));
   return forkJoin(taskObs)
}

Moving one step outward, we can augment each date of an array of dates with tasks that belong to that date like this

augmentDates(dates) {
  if (dates.length === 0) {
    return of([]);
  }
  const taskDatesIds = uniq(dates.map(dt => dt.taskId));
  return fetchTasks(taskDatesIds).pipe(
    map(tasks => {
      return dates.map(date => {
        return {...date, task: task.find(t => t.id === date.taskId)}
      })
    })
  )
}

Now we can augment a session with its dates like this

augmentSessionWithDates(session) {
  return this.TaskdatesService.getBySessionId(session.id).pipe(
    switchMap(dates => augmentDates(dates).pipe( 
      map(augmentedDates => {
        return {...session, dates: augmentedDates};
      })
    ))
  )
}

We can now complete the fetchAugmentedSession method, adding also the part that augment the session with dates info like this

fetchAugmentedSession(courseSessionId) {
  return this.sessionService.get(courseSessionId).pipe(
     switchMap(session => this.augmentSessionWithCourse(session)),
     switchMap(session => this.augmentSessionWithDates(session)),
  )
}

In this way you have split your logic in smaller chunks, which are easier to test and, hopefully, easier to read.

I did not have any playground to test the code, so it is very well possible that there are typos in it. I hope though that the logic is clear enough.

Picci
  • 16,775
  • 13
  • 70
  • 113
  • Thank you, your method clarified a lot of things, the only problem that the final augmented applications observable is working fine if I remove the Dates array part. I found it easier to create the field and course because they are only an attribute in the object. the main problem was with the Dates[] array – Ilyas Ghomrani Sep 01 '20 at 10:15
  • the problem if fired when the dates array is empty. I have no idea how to apply a condition on it, see the edit – Ilyas Ghomrani Sep 01 '20 at 13:19
  • The point is that the function you pass to `switchMap` as parameter must return an Observable. In your code, in case `dates` is empty you return `void` hence the error. I try to edit my response with a suggestion for this case. – Picci Sep 01 '20 at 13:30
1

That's a lot of async nesting, perhaps there is another way to go about this?

If you are trying to take multiple observables and have a single subscription (say, be able to react to a number of events in a similar manner), then use rxjs merge.

If you are wanting to do a lot of individual subscriptions, like subscribe to users, foo, and bar and then use all of their values for some logic, use rxjs forkJoin.

If you state a clear objective it would be easier to offer guidance, but I know that level of rxjs operator nesting is not easily maintained.

iamaword
  • 1,327
  • 8
  • 17
  • all I need is to get the data from 5 different entities joined in a specific structure to present it in the template after applying minor logic on it. the code I have written is inspired from this [article](https://medium.com/@joaqcid/how-to-inner-join-data-from-multiple-collections-on-angular-firebase-bfd04f6b36b7) – Ilyas Ghomrani Sep 01 '20 at 01:10
  • 1
    depending on how you need things structured, I still feel you should be able to just do one large combineLatest. similar to: combineLatest(timerOne$, timerTwo$, timerThree$).subscribe( ([timerValOne, timerValTwo, timerValThree]) => { see https://www.learnrxjs.io/learn-rxjs/operators/combination/combinelatest – iamaword Sep 01 '20 at 12:57
  • the code above works fine if I exclude the part of the dates array, also when I applied the solution to the other question I face the same problem. also, the other answer is explaining the logic more then I did. – Ilyas Ghomrani Sep 01 '20 at 13:04
  • the problem if fired when the dates array is empty. I have no idea how to apply a condition on it, see the edit – Ilyas Ghomrani Sep 01 '20 at 13:20