How to compose/pipe functions when dealing with objects? - javascript

Say I have a Request object:
{
user: { /* user data: username, email, etc. */ }
post: { /* post data: content, date, etc. */ }
}
Example of a Request object:
{
user: {
id: '123'
username: 'kibe'
email: 'blabla#gmail.com'
}
post: {
content: 'my new post!'
date: '20/02/2004'
}
}
Now, I have two functions: validateUser and validatePost. Both of them return a Maybe monad, because they might fail.
How can I then do something like this?
function savePost (request) {
return request
|> validateUser // if validateUser returns either Success(user) or Failure(error), how would I pass down the post?
|> validatePost
|> savePostToDb
|> ok
}
Should I create a function validateRequest which composes validateUser and validatePost? But then, how would I only give the post object to savePostToDb? What if savePostToDb also requires the user ID?
function savePost (request) {
return request
|> validateRequest // returns an Either monad
|> savePostToDb // only requires user ID and the post, how would I pass these down?
|> ok
}
Hopefully these questions make sense. I am new to FP and although I am understanding its paradigms, I am failing to design a simple program
Thanks!

prop
We write a simple prop function which safely looks up properties on your object. If they are present, we get a Just-wrapped value, otherwise we get Nothing -
const fromNullable = x =>
x == null
? Nothing
: Just(x)
const prop = k => t =>
fromNullable(t[k])
props
You have nested properties though, so let's make it so we can dig arbitrarily deep into your object using any sequence of props -
const props = (k, ...ks) => t =>
ks.length
? prop(k)(t).bind(props(...ks))
: prop(k)(t)
validate
Now we can write a simple validate coroutine -
function* validate (t)
{ const id = yield props("user", "id")(t)
const username = yield props("user", "username")(t)
const email = yield props("user", "email")(t)
const post = yield props("post", "content")(t)
const date = yield props("post", "date")(t)
return Just({ id, username, email, post, date }) // <- Just(whateverYouWant)
}
const doc =
{ user:
{ id: '123'
, username: 'kibe'
, email: 'blabla#gmail.com'
}
, post:
{ content: 'my new post!'
, date: '20/02/2004'
}
}
coroutine(validate(doc)).bind(console.log)
With a valid doc we see -
{
id: '123',
username: 'kibe',
email: 'blabla#gmail.com',
post: 'my new post!',
date: '20/02/2004'
}
With an invalid document, otherdoc -
const otherdoc =
{ user:
{ id: '123'
, username: 'kibe'
, // <- missing email
}
, post:
{ content: 'my new post!'
, // <- missing date
}
}
coroutine(validate(otherdoc)).bind(console.log)
// no effect because validate returns a Nothing!
coroutine
Implementation of coroutine is simple and most importantly it is generic for any monad -
function coroutine (f)
{ function next (v)
{ let {done, value} = f.next(v)
return done ? value : value.bind(next)
}
return next()
}
Maybe
Lastly we supply implementation for Nothing and Just -
const Nothing =
({ bind: _ => Nothing })
const Just = v =>
({ bind: f => f(v) })
demo
Expand the snippet below to verify the results in your own browser -
const Nothing =
({ bind: _ => Nothing })
const Just = v =>
({ bind: f => f(v) })
const fromNullable = x =>
x == null
? Nothing
: Just(x)
const prop = k => t =>
fromNullable(t[k])
const props = (k, ...ks) => t =>
ks.length
? prop(k)(t).bind(props(...ks))
: prop(k)(t)
function coroutine (f)
{ function next (v)
{ let {done, value} = f.next(v)
return done ? value : value.bind(next)
}
return next()
}
function* validate (t)
{ const id = yield props("user", "id")(t)
const username = yield props("user", "username")(t)
const email = yield props("user", "email")(t)
const post = yield props("post", "content")(t)
const date = yield props("post", "date")(t)
return Just({ id, username, email, post, date })
}
const doc =
{user:{id:'123',username:'kibe',email:'blabla#gmail.com'},post:{content:'my new post!',date:'20/02/2004'}}
coroutine(validate(doc)).bind(console.log)
related reading
How do pipes and monads work together in JavaScript?
Ramda: Fold an object
How should i sort a list inside an object in functional programming with different logic based on different condition?
Sort objects in array with dynamic nested property keys

Yes you should create validateRequest and a savePostToDb Methodes which give you a boolean back. Then you can simple create two Maybe-Functions with thos two new methodes and compose it togheter. Look at my example how I would do it simple:
// Here some simple Left/Right to Just and Nothing (Maybe)-Monade function to work with
const Left = x => f => _ => f(x);
const Right = x => _ => g => g(x);
const Nothing = Left();
const Just = Right;
// your maybe-validateRequest function
const maybeValidateRequest = request =>
validateRequest(request)
? Just(user)
: Nothing
// your maybe-postToDb function
const maybePostToDb = request =>
savePostToDb(request)
? Just(request)
: Nothing
// and finally compose those two maybes and do the postRequest finally
const savePost = request =>
maybeValidateRequest(request)
(() => console.error("Validation failed"))
(_ =>
maybePostToDb(request)
(() => console.error("Save to DB failed")))
(_ => postRequest(request)) // when it's everything passed,the postRequest can be safely made

Related

How implement types properly?

I'm new to Typescript and have been doing a refactor a colleague code, I'm currently doing a typecheck and removing all any types. The goal is to make an MSGraph API call and return the a JSON file that translated into BirthdayPerson with a name, birthday date and a ID
I've been trying to a assign a type instead of any in the following code, but whether I assign number, string or any other type a different error will show up.
Perhaps I'm not tackling the solution correctly:
graph.ts
* #param accessToken
* #param endpoint url to call from MS Graph
*/
async function callMsGraph(accessToken: string, endpoint: string) {
const headers = new Headers();
const bearer = `Bearer ${accessToken}`;
headers.append('Authorization', bearer);
const options = {
method: 'GET',
headers,
};
try {
return fetch(endpoint, options);
} catch (error) {
console.error(error);
throw error;
}
}
export const callMsGraphWithoutPagination = async (
accessToken: string,
url: string,
dataToReturn: any[] = []
): Promise<any[]> => {
try {
const data = await callMsGraph(accessToken, url);
const dataJson = await data.json();
const newData = dataToReturn.concat(dataJson.value);
if (dataJson['#odata.nextLink']) {
const NEXT_URL = dataJson['#odata.nextLink'].split('/v1.0')[1];
return await callMsGraphWithoutPagination(
accessToken,
process.env.REACT_APP_GRAPH_URL + NEXT_URL,
newData
);
}
return dataToReturn.concat(dataJson.value);
} catch (error) {
/* eslint-disable no-console */
console.error(error);
/* eslint-enable no-console */
throw error;
}
};
export default callMsGraph;
useUsers.tsx
export const useUsers = () => {
const token = useToken();
const [users, setUsers] = React.useState<BirthdayPerson[]>([]);
React.useEffect(() => {
if (token) {
callMsGraphWithoutPagination(token, graphConfig.usersEndpoint).then(async (data: any) => {
const processedData: any[] = await Promise.all(
data.map(async (element: any) => {
const user = await callMsGraph(token, graphConfig.userBirthdayEndpoint(element.id));
const userJson = await user.json();
const image = await callMsGraph(token, graphConfig.userPhotoEndpoint(element.id));
const blob = await image.blob();
const returnElement: BirthdayPerson = {
displayName: element.displayName,
birthday: userJson.value,
id: element.id,
};
if (blob !== null) {
window.URL = window.URL || window.webkitURL;
returnElement.picture = window.URL.createObjectURL(blob);
}
return returnElement;
})
);
setUsers([].concat(...processedData));
});
}
}, [token]);
return users;
};
helpers.ts
interface IUpdateData {
slideCount: number;
}
const sortAndFilterBirthdays = (people: BirthdayPerson[], daysToGet: number) =>
people
.sort((firstEl, secondEl) => sortDate(firstEl.birthday, secondEl.birthday))
.filter(({ birthday }) => filterByAmountOfDays({ date: birthday, daysAfter: daysToGet }));
const getBirthdays: any = (people: BirthdayPerson[], daysToGet: number) => {
const validBirthdays = people.filter((element: any) => {
const year = moment(element.birthday).year();
return year !== 0;
});
const result = sortAndFilterBirthdays(validBirthdays, daysToGet);
// if it's okay
if (result.length > 1 && daysToGet <= 30) {
return result;
}
// if not okay, filters by future dates, concats with 'next year' dates, returns 2 dates
const fallbackResult = validBirthdays
.sort((firstEl, secondEl) => sortDate(firstEl.birthday, secondEl.birthday))
.filter((person: BirthdayPerson) => {
const currentYear = moment().year();
const date = moment(person.birthday, DATE_FORMAT).set('years', currentYear);
return moment().diff(date, 'days') <= 0;
});
return fallbackResult.concat(validBirthdays).splice(0, 2);
};
Any help or indication would be great!
From all the changes I've done another object will complain that Type 'x' is not assignable to type 'string'
Firstly you need to somehow type responses from API, because right now, as you have seen, call to .json() on Response object returns unknown, which make sense because no one knows what response the server returns. You may know what response it is expected to return, but not what it actually does.
Ideally therefore you need a parser that will verify that the response has correct structure and throws an error otherwise. There are libraries such as superstruct, yup, joi and others which you can use for this. Of course this is a lot of work and will need refactoring. Or if you don't care enough you can just cast the response object to appropriate type, but then if server returns something unexpected and the application cannot handle it, it's your fault.
Example with response parsing using superstruct
import {string, number, create, Infer} from 'superstruct'
// I assume `BirthdayUser` is just a string, but you can type more complex objects as well
const BirthdayUser = string()
// This is so that you don't have to list fields twice: once
// for the parser and once for typescript
type BirthdayUser = Infer<typeof BirthdayUser>
// Then use the parser
const response = await callMsGraph(acessToken, url)
const userJson = await response.json()
// user variable has inferred appropriate type, and if the response doesn't
// comply with the definition of `BirthdayUser` an error will be thrown
// Also I assume MS graph doesn't just return plain value but wraps it in an object with `value` field, so writing it here
const user = create(userJson, object({ value: BirthdayUser }))
Example of "I don't care enough" solution
type BirthdayUser = string
const response = await callMsGraph(accessToken, url)
// Same thing with wrapping into object with `value` field
const userJson = (await response.json()) as {value: BirthdayUser}
This is a bit awkward, because API call returns Response object and not the actual data. It might be easier to work with if you move parsing and casting logic inside of callMsGraph. It's not obligatory of course, but I still provide an example because after that you need to type callMsGraphWithoutPagination and it will use the same idea
import {object, create, Struct} from 'superstruct'
async function callMsGraphParsed<T>(
accessToken: string,
url: string,
// Since we need information about object structure at runtime, just making function
// generic is not enough, you need to pass the parser structure as an argument
struct: Struct<T>
) {
// ...
const response = await fetch(...)
const json = await response.json()
// Same, verifies that response complies to provided structure, if it
// does returns type object (of type `T`), otherwise throws an error
return create(json, object({ value: struct }))
}
async function callMsGraphLazy<T>(accessToken: string, url: string) {
// ...
const response = await fetch(...)
const json = await response.json()
return json as {value: T}
}
However I only call .json() here, if you want to use this solution, you will then need either a different function or another argument if you also want it to call .blob() for some API calls.
Now you type callMsGraphWithoutPagination using in the same way:
export const callMsGraphWithoutPaginationParsed = async <T>(
accessToken: string,
url: string,
dataToReturn: T[] = [],
struct: Struct<T>,
): Promise<T[]> => {
// If you rewrote `callMsGraph` to use parsing
const dataJson = await callMsGraph(accessToken, url, struct);
const newData = dataToReturn.concat(dataJson.value);
// ...
}
export const callMsGraphWithoutPaginationLazy= async <T>(
accessToken: string,
url: string,
dataToReturn: T[] = [],
): Promise<T[]> => {
// If you left `callMsGraph` as is
const data = await callMsGraph(accessToken, url);
const dataJson = (await data.json()) as {value: T}
const newData = dataToReturn.concat(dataJson.value);
// ...
}
Then use it
// Not sure if you are requesting `BirthdayUser` array here or some other entity, so change it to whatever you expect to receive
callMsGraphWithoutPagination<BirthdayUser>(token, graphConfig.usersEndpoint).then(async (data) => {
// "data" is inferred to have type BirthdayUser[]
data.map(element => {
// "element" is inferred to have type BirthdayUser
})
})
Also everywhere I wrote "I assume" and "Not sure" is missing info that you should probably have provided in the question. It didn't turn out to be relevant for me, but it could have. Good luck!

Object is returned before promises have time to resolve

I am trying to build a web scraper to get informations about some products and store them inside a database. I'm getting the HTML source code with Nightmare (because javascript code has to run on the server before the page content is created) then I'm parsing that source with Cheerio. Once I do the parsing there are some images I have to download for the products. I have a simple download function and, based on if the image which I'm trying to download is available or not on the server, I'd like to return a string (or an array of strings) containing either the image name (which I downloaded) or a default image name from my computer. I tried calling the download function as a promise, I tried passing Promise.all() when I know there are multiple images to download, but to no avail. While I'm positive my code is working (the images are downloaded as should, the final object looks great at almost every property and value), it is the images properties fields which, when I'm printing the object to the console, still holds [Promise] / [ Promise { } ] and I'm not quite sure how to solve this matter. I'm positive those promises resolve, but they're not resolved when I'm outputting the resulting object to the console. And that's a problem, 'cause I have to pass that object to be stored in the database and I don't think they'll be resolved then.
The code (minus the exact links) is down below:
const cheerio = require('cheerio')
const nightmare = require('nightmare')()
const download = require('image-downloader')
const settings = new function() {
this.baseURL = 'https://baseurl.whatever'
this.urlSearch = `${this.baseURL}/Product/Search?keyword=`
this.urlVariant = 'https://cdn.baseurl.whatever/Variant/'
this.urlProduct = 'https://cdn.baseurl.whatever/Product/'
this.imgPath = './img/'
}
var review_id = 0
function downloadImage(url, filepath, success, error) {
return download.image({ url, dest: filepath }).then(success, error)
}
const url = 'https://someurl.nevermind.meh/product?pid=50M3NUMB3R',
code = '50M3C0D3'
async function scrapeProduct(code) {
const product = await nightmare.goto(url)
.wait()
.evaluate(() => document.body.innerHTML)
.end()
.then(body => console.log(loadProduct(body, code)))
.catch(err => console.log(`There was an error: [${err}]`))
}
function loadProduct(body, code) {
$ = cheerio.load(body)
return {
title: $('li.LongName').text().trim(),
category: $('a#categoryTitleLink').text().trim(),
min_price: parseFloat($('span.MinPrice').text()),
max_price: parseFloat($('span.MaxPrice')?.text()) || parseFloat($('span.MinPrice').text()),
points: parseFloat($('div.AddtoCartUnderText span').text()),
variants: [...$('div.productDetailClassicRnd')].map(variant => {
const $field = $(variant).find('input'),
item_code = $field.attr('item_code')
if (item_code.split('-')[0] == code) return null
return {
code: item_code.split('-')[0],
title: $field.attr('item_name'),
image: downloadImage(
`${settings.urlVariant}${item_code.replace(' ', '%20')}`,
`${settings.imgPath}${item_code}`,
result => result.filename.split('/').reverse()[0],
_ => 'variant_default-VC.jpg'
)
}
}).filter(variant => variant !== null),
images: [...$('img#imgProduct')].map(image => {
const $image = $(image),
source = $image.attr('src')
return downloadImage(
source,
`${settings.imgPath}${source.split('/').reverse()[0]}`,
result => result.filename.split('/').reverse()[0],
_ => 'product_default.jpg'
)
}),
other_images: [...$('img.productDetailOtherIMG')].map(image => {
const $image = $(image),
source = $image.attr('src')
// Check if the other image is not a default one
if (/default_\d{1,2}/.test(source)) return null
return downloadImage(
source,
`${settings.imgPath}${source.split('/').reverse()[0]}`,
result => result.filename.split('/').reverse()[0],
_ => null
)
}).filter(other_image => other_image !== null),
how_to_use: $('span#HowToUse p')?.text().trim() || "",
technical_description: $('span#TechnicalDescription p')?.text().trim() || "",
product_description: $('span#ProductDescription p')?.text().trim() || "",
bought_with: [...$('a.redirectProductId')].map(item => $(item).attr('href').match(/=(\d+)$/)[1]),
rank: $('div.productAverageMainDiv').find('i.activeStar').length,
reviews_count: parseInt($('span#spnReviewCount').text()),
reviews: [...$('div.customerReviewsMainDiv')].map(review => {
const $review = $(review)
return {
id: ++review_id,
author: $review.find('div.customerName').text().trim(),
posted_at: $review.find('div.starIconsForReviews span').text().trim(),
rank: $review.find('span.productAverageMainDiv').find('i.activeStar').length,
message: $review.find('div.customerReviewDetail span').text().trim()
}
})
}
}
scrapeProduct(code)
I can't even filter the null values from my resulting array of image names because those promises don't resolve once I reach the filter function. I somehow was under the impression that
images: downloadImage(
URL,
filepath,
resolve() {},
reject() {}
)
will wait until the downloadImage function returns a value to the image property and then the filter function will be executed. On the other hand, given that I guess the execution flows to filter function long before my downloadImage function has any chance of resolving the promise, I'd chain a .then() to the downloadImage, but I can't, because the downloadImage is inside the return of the map() function - which is the one followed by the .filter() function in the code.
Any help would be much appreciated! Thank you!
P.S.: I'm pretty sure there's something elementary (logical) which I'm overseeing or I didn't understand properly and I apologize for wasting your time, but I'm struggling with this thing for two days now and I don't seem to have any more ideas ^_^
You are getting an array of Promises returned by the map() method, so you will need to use Promise.all() or one of its variants.
For example, here you get the array of promises of "images", then you use Promise.all() to wait for all promises to be resolved, and finally you chain a then() to use the values.
const imagesPromises = [...$('img#imgProduct')].map(image => {
const $image = $(image),
source = $image.attr('src')
return downloadImage(
source,
`${settings.imgPath}${source.split('/').reverse()[0]}`,
result => result.filename.split('/').reverse()[0],
_ => 'product_default.jpg'
)
})
return Promise.all(imagesPromises)
.then(images => {
return {
images,
...
}
})
And here you have a possible implementation:
function loadProduct(body, code) {
$ = cheerio.load(body)
const result = {
title: $('li.LongName').text().trim(),
category: $('a#categoryTitleLink').text().trim(),
min_price: parseFloat($('span.MinPrice').text()),
max_price: parseFloat($('span.MaxPrice')?.text()) || parseFloat($('span.MinPrice').text()),
points: parseFloat($('div.AddtoCartUnderText span').text()),
variants: [...$('div.productDetailClassicRnd')].map(variant => {
const $field = $(variant).find('input'),
item_code = $field.attr('item_code')
if (item_code.split('-')[0] == code) return null
return {
code: item_code.split('-')[0],
title: $field.attr('item_name'),
image: downloadImage(
`${settings.urlVariant}${item_code.replace(' ', '%20')}`,
`${settings.imgPath}${item_code}`,
result => result.filename.split('/').reverse()[0],
_ => 'variant_default-VC.jpg'
)
}
}).filter(variant => variant !== null),
how_to_use: $('span#HowToUse p')?.text().trim() || "",
technical_description: $('span#TechnicalDescription p')?.text().trim() || "",
product_description: $('span#ProductDescription p')?.text().trim() || "",
bought_with: [...$('a.redirectProductId')].map(item => $(item).attr('href').match(/=(\d+)$/)[1]),
rank: $('div.productAverageMainDiv').find('i.activeStar').length,
reviews_count: parseInt($('span#spnReviewCount').text()),
reviews: [...$('div.customerReviewsMainDiv')].map(review => {
const $review = $(review)
return {
id: ++review_id,
author: $review.find('div.customerName').text().trim(),
posted_at: $review.find('div.starIconsForReviews span').text().trim(),
rank: $review.find('span.productAverageMainDiv').find('i.activeStar').length,
message: $review.find('div.customerReviewDetail span').text().trim()
}
})
}
const imagesPromises = [...$('img#imgProduct')].map(image => {
const $image = $(image),
source = $image.attr('src')
return downloadImage(
source,
`${settings.imgPath}${source.split('/').reverse()[0]}`,
result => result.filename.split('/').reverse()[0],
_ => 'product_default.jpg'
)
})
const otherImagesPromises = [...$('img.productDetailOtherIMG')].map(image => {
const $image = $(image),
source = $image.attr('src')
// Check if the other image is not a default one
if (/default_\d{1,2}/.test(source)) return null
return downloadImage(
source,
`${settings.imgPath}${source.split('/').reverse()[0]}`,
result => result.filename.split('/').reverse()[0],
_ => null
)
})
return Promise.all(imagesPromises)
.then(images => {
result.images = images
return Promise.all(otherImagesPromises)
})
.then(otherImages => {
result.other_images = otherImages.filter(other_image => other_image !== null)
return result
})
}
scrapeProduct(code).then(product => console.log(product))

Context is undefined in translation module

I tried to add a call to an endpoint in order to get translation. I have like this :
const loadLocales = async () => {
const context = require.context('./locales', true);
const data = await ApiService.post(`${translationToolUrl}/gateway/translations`, { project: 'myProject' });
const messages = context.keys()
.map((key) => ({ key, locale: key.match(/[-a-z0-9_]+/i)[0] }))
.reduce((msgs, { key, locale }) => ({
...msgs,
[locale]: extendMessages(context(key)),
}), {});
return { context, messages };
};
const { context, messages } = loadLocales();
i18n = new VueI18n({
locale: 'en',
fallbackLocale: 'en',
silentFallbackWarn: true,
messages,
});
if (module.hot) {
module.hot.accept(context.id, () => {
const { messages: newMessages } = loadLocales();
Object.keys(newMessages)
.filter((locale) => messages[locale] !== extendMessages(newMessages[locale]))
.forEach((locale) => {
const msgs = extendMessages(newMessages[locale]);
messages[locale] = msgs;
i18n.setLocaleMessage(locale, msgs);
});
});
}
I added this request : ApiService.post. But I have the error TypeError: context is undefined droped at this line module.hot.accept(context.id.... Have you an idea how I can solve that ? My scope was to add this request in order to get translations from database and from .json files for now. I want to do a merge between both for now, in the feature I will get only from database but this will be done step by step.
The problem is, that you trying to declare multiple const in the wrong way, independently of trying to declaring them twice. This shows in:
const { context, messages } = loadLocales();
This would couse context and messages to be undefined. This won´t give an error, as I replicated in a small example:
const {first, second} = 'Testing'
console.log(first)
console.log(second)
Both, first and second, will be undefined. If you try to declare multiple const at once, you need to do it this way:
const context = loadLocales(), messages = loadLocales();

Cloud Firestore: Query two collection [duplicate]

I have a Cloud Firestore DB with the following structure:
users
[uid]
name: "Test User"
posts
[id]
content: "Just some test post."
timestamp: (Dec. 22, 2017)
uid: [uid]
There is more data present in the actual DB, the above just illustrates the collection/document/field structure.
I have a view in my web app where I'm displaying posts and would like to display the name of the user who posted. I'm using the below query to fetch the posts:
let loadedPosts = {};
posts = db.collection('posts')
.orderBy('timestamp', 'desc')
.limit(3);
posts.get()
.then((docSnaps) => {
const postDocs = docSnaps.docs;
for (let i in postDocs) {
loadedPosts[postDocs[i].id] = postDocs[i].data();
}
});
// Render loadedPosts later
What I want to do is query the user object by the uid stored in the post's uid field, and add the user's name field into the corresponding loadedPosts object. If I was only loading one post at a time this would be no problem, just wait for the query to come back with an object and in the .then() function make another query to the user document, and so on.
However because I'm getting multiple post documents at once, I'm having a hard time figuring out how to map the correct user to the correct post after calling .get() on each post's user/[uid] document due to the asynchronous way they return.
Can anyone think of an elegant solution to this issue?
It seems fairly simple to me:
let loadedPosts = {};
posts = db.collection('posts')
.orderBy('timestamp', 'desc')
.limit(3);
posts.get()
.then((docSnaps) => {
docSnaps.forEach((doc) => {
loadedPosts[doc.id] = doc.data();
db.collection('users').child(doc.data().uid).get().then((userDoc) => {
loadedPosts[doc.id].userName = userDoc.data().name;
});
})
});
If you want to prevent loading a user multiple times, you can cache the user data client side. In that case I'd recommend factoring the user-loading code into a helper function. But it'll be a variation of the above.
I would do 1 user doc call and the needed posts call.
let users = {} ;
let loadedPosts = {};
db.collection('users').get().then((results) => {
results.forEach((doc) => {
users[doc.id] = doc.data();
});
posts = db.collection('posts').orderBy('timestamp', 'desc').limit(3);
posts.get().then((docSnaps) => {
docSnaps.forEach((doc) => {
loadedPosts[doc.id] = doc.data();
loadedPosts[doc.id].userName = users[doc.data().uid].name;
});
});
After trying multiple solution I get it done with RXJS combineLatest, take operator. Using map function we can combine result.
Might not be an optimum solution but here its solve your problem.
combineLatest(
this.firestore.collection('Collection1').snapshotChanges(),
this.firestore.collection('Collection2').snapshotChanges(),
//In collection 2 we have document with reference id of collection 1
)
.pipe(
take(1),
).subscribe(
([dataFromCollection1, dataFromCollection2]) => {
this.dataofCollection1 = dataFromCollection1.map((data) => {
return {
id: data.payload.doc.id,
...data.payload.doc.data() as {},
}
as IdataFromCollection1;
});
this.dataofCollection2 = dataFromCollection2.map((data2) => {
return {
id: data2.payload.doc.id,
...data2.payload.doc.data() as {},
}
as IdataFromCollection2;
});
console.log(this.dataofCollection2, 'all feeess');
const mergeDataFromCollection =
this.dataofCollection1.map(itm => ({
payment: [this.dataofCollection2.find((item) => (item.RefId === itm.id))],
...itm
}))
console.log(mergeDataFromCollection, 'all data');
},
my solution as below.
Concept: You know user id you want to get information, in your posts list, you can request user document and save it as promise in your post item. after promise resolve then you get user information.
Note: i do not test below code, but it is simplify version of my code.
let posts: Observable<{}[]>; // you can display in HTML directly with | async tag
this.posts = this.listenPosts()
.map( posts => {
posts.forEach( post => {
post.promise = this.getUserDoc( post.uid )
.then( (doc: DocumentSnapshot) => {
post.userName = doc.data().name;
});
}); // end forEach
return posts;
});
// normally, i keep in provider
listenPosts(): Observable<any> {
let fsPath = 'posts';
return this.afDb.collection( fsPath ).valueChanges();
}
// to get the document according the user uid
getUserDoc( uid: string ): Promise<any> {
let fsPath = 'users/' + uid;
return this.afDb.doc( fsPath ).ref.get();
}
Note: afDb: AngularFirestore it is initialize in constructor (by angularFire lib)
If you want to join observables instead of promises, use combineLatest. Here is an example joining a user document to a post document:
getPosts(): Observable<Post[]> {
let data: any;
return this.afs.collection<Post>('posts').valueChanges().pipe(
switchMap((r: any[]) => {
data = r;
const docs = r.map(
(d: any) => this.afs.doc<any>(`users/${d.user}`).valueChanges()
);
return combineLatest(docs).pipe(
map((arr: any) => arr.reduce((acc: any, cur: any) => [acc].concat(cur)))
);
}),
map((d: any) => {
let i = 0;
return d.map(
(doc: any) => {
const t = { ...data[i], user: doc };
++i;
return t;
}
);
})
);
}
This example joins each document in a collection, but you could simplify this if you wanted to just join one single document to another.
This assumes your post document has a user variable with the userId of the document.
J

How to join multiple documents in a Cloud Firestore query?

I have a Cloud Firestore DB with the following structure:
users
[uid]
name: "Test User"
posts
[id]
content: "Just some test post."
timestamp: (Dec. 22, 2017)
uid: [uid]
There is more data present in the actual DB, the above just illustrates the collection/document/field structure.
I have a view in my web app where I'm displaying posts and would like to display the name of the user who posted. I'm using the below query to fetch the posts:
let loadedPosts = {};
posts = db.collection('posts')
.orderBy('timestamp', 'desc')
.limit(3);
posts.get()
.then((docSnaps) => {
const postDocs = docSnaps.docs;
for (let i in postDocs) {
loadedPosts[postDocs[i].id] = postDocs[i].data();
}
});
// Render loadedPosts later
What I want to do is query the user object by the uid stored in the post's uid field, and add the user's name field into the corresponding loadedPosts object. If I was only loading one post at a time this would be no problem, just wait for the query to come back with an object and in the .then() function make another query to the user document, and so on.
However because I'm getting multiple post documents at once, I'm having a hard time figuring out how to map the correct user to the correct post after calling .get() on each post's user/[uid] document due to the asynchronous way they return.
Can anyone think of an elegant solution to this issue?
It seems fairly simple to me:
let loadedPosts = {};
posts = db.collection('posts')
.orderBy('timestamp', 'desc')
.limit(3);
posts.get()
.then((docSnaps) => {
docSnaps.forEach((doc) => {
loadedPosts[doc.id] = doc.data();
db.collection('users').child(doc.data().uid).get().then((userDoc) => {
loadedPosts[doc.id].userName = userDoc.data().name;
});
})
});
If you want to prevent loading a user multiple times, you can cache the user data client side. In that case I'd recommend factoring the user-loading code into a helper function. But it'll be a variation of the above.
I would do 1 user doc call and the needed posts call.
let users = {} ;
let loadedPosts = {};
db.collection('users').get().then((results) => {
results.forEach((doc) => {
users[doc.id] = doc.data();
});
posts = db.collection('posts').orderBy('timestamp', 'desc').limit(3);
posts.get().then((docSnaps) => {
docSnaps.forEach((doc) => {
loadedPosts[doc.id] = doc.data();
loadedPosts[doc.id].userName = users[doc.data().uid].name;
});
});
After trying multiple solution I get it done with RXJS combineLatest, take operator. Using map function we can combine result.
Might not be an optimum solution but here its solve your problem.
combineLatest(
this.firestore.collection('Collection1').snapshotChanges(),
this.firestore.collection('Collection2').snapshotChanges(),
//In collection 2 we have document with reference id of collection 1
)
.pipe(
take(1),
).subscribe(
([dataFromCollection1, dataFromCollection2]) => {
this.dataofCollection1 = dataFromCollection1.map((data) => {
return {
id: data.payload.doc.id,
...data.payload.doc.data() as {},
}
as IdataFromCollection1;
});
this.dataofCollection2 = dataFromCollection2.map((data2) => {
return {
id: data2.payload.doc.id,
...data2.payload.doc.data() as {},
}
as IdataFromCollection2;
});
console.log(this.dataofCollection2, 'all feeess');
const mergeDataFromCollection =
this.dataofCollection1.map(itm => ({
payment: [this.dataofCollection2.find((item) => (item.RefId === itm.id))],
...itm
}))
console.log(mergeDataFromCollection, 'all data');
},
my solution as below.
Concept: You know user id you want to get information, in your posts list, you can request user document and save it as promise in your post item. after promise resolve then you get user information.
Note: i do not test below code, but it is simplify version of my code.
let posts: Observable<{}[]>; // you can display in HTML directly with | async tag
this.posts = this.listenPosts()
.map( posts => {
posts.forEach( post => {
post.promise = this.getUserDoc( post.uid )
.then( (doc: DocumentSnapshot) => {
post.userName = doc.data().name;
});
}); // end forEach
return posts;
});
// normally, i keep in provider
listenPosts(): Observable<any> {
let fsPath = 'posts';
return this.afDb.collection( fsPath ).valueChanges();
}
// to get the document according the user uid
getUserDoc( uid: string ): Promise<any> {
let fsPath = 'users/' + uid;
return this.afDb.doc( fsPath ).ref.get();
}
Note: afDb: AngularFirestore it is initialize in constructor (by angularFire lib)
If you want to join observables instead of promises, use combineLatest. Here is an example joining a user document to a post document:
getPosts(): Observable<Post[]> {
let data: any;
return this.afs.collection<Post>('posts').valueChanges().pipe(
switchMap((r: any[]) => {
data = r;
const docs = r.map(
(d: any) => this.afs.doc<any>(`users/${d.user}`).valueChanges()
);
return combineLatest(docs).pipe(
map((arr: any) => arr.reduce((acc: any, cur: any) => [acc].concat(cur)))
);
}),
map((d: any) => {
let i = 0;
return d.map(
(doc: any) => {
const t = { ...data[i], user: doc };
++i;
return t;
}
);
})
);
}
This example joins each document in a collection, but you could simplify this if you wanted to just join one single document to another.
This assumes your post document has a user variable with the userId of the document.
J

Categories

Resources