masalibの日記

システム開発、運用と猫の写真ブログです

React Firebase入門 Firestoreのコレクション(テーブル)のJOIN

WEBアプリで使うデータは1つのコレクション(テーブル)に収まるわけではなく様々のテーブルにはいります。そのデータをアプリ側で使うにはテーブルのJOINが必要です。 FireStoreにmongoDBのlookupみたいな事ができる関数が用意されているのかなと思っていたら用意されていませんでした😢。 自前で結合しないといけないという悲しい事態になりました。

この記事はReact Firebase入門シリーズです 1-1・ Firebase初期設定とFirebaseAuthのSignUp
1-2・ 「react-hook-form」を入れてみた
1-3・ AuthのSignUpの通信エラー対応
2 ・ FirebaseAuthのログイン処理
3 ・ FirebaseAuthのログイン認証とログアウト処理
4 ・ FirebaseAuthのパスワード初期化処理
5 ・ FirebaseAuthのメールアドレスの有効化
6 ・ FirebaseAuthのメールアドレスとパスワード変更
7 ・ FirebaseAuthの表示名変更
8-1・ FirebaseAuthのアバター変更:基礎
8-2・ FirebaseAuthのアバター変更:画像切り抜き
9-1・FirestoreのCRUD
9-2・Firestoreのデータ取得補足
9-3・Firestoreのページネーション処理
9-3・Firestoreのコレクション(テーブル)のJOIN 今ここ

やりたい事

次の構造のFirestore コレクションがあります。

  • members(メンバー)

    • docId (メンバーID)
    • email (メールアドレス)
    • displayName (ハンドル名)
    • departmentId(部署ID)
  • departments(部署データ)

    • departmentId(部署ID)
    • departmentName (部署名)

このデータを結合させて下記のような一覧を表示させたいのです。

  • docId (メンバーID)
  • email (メールアドレス)
  • displayName (ハンドル名)
  • departmentName(部署名)

前提

  • 部署データは更新があまりない
  • 部署データはFirebaseのコンソールから直接insertしたので管理画面はない

コレクションのJOINの修正内容

前回のソースから改変した部分を記載したいと思います。

masalib.hatenablog.com

プログラム:index.js(一覧処理)

全部をみる人は少ないと思いますが念のための記載します

全文(長文なの注意してください)

import React, { useState, useEffect,useRef } from "react";
import  { db }  from "../../firebase"
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import {
    Typography,
    Table,          
    TableBody,      
    TableCell,      
    TableContainer, 
    TableHead,      
    TableRow,       
    Paper,          
    Fab,
  } from '@material-ui/core';
import { Link } from "react-router-dom"
import moment from 'moment';
import {
    NavigateBefore,
    NavigateNext
 } from "@material-ui/icons";

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    container: {
        padding: 16, 
        margin: 'auto',
        maxWidth: 480
    },
    table: {
        margin: 'auto',
        minWidth: 600,
        maxWidth: 960

    },
    largeIcon: {
        width: 60,
        height: 60,
    },
  })
);

const LIMIT_COUNT = 5

export const Index = () => {

    // 入力用DOMノードへの参照を保持する
    const inputRef = useRef();
    const classes = useStyles();//Material-ui
    //const history = useHistory()
    const [list, setList] = useState([])
    const [firstRecord, setFirstRecord] = useState()
    const [lastRecord, setLastRecord] = useState()
    const [currentfirstRecord, setCurrentFirstRecord] = useState()
    const [currentlastRecord, setCurrentLastRecord] = useState()
    const [hasPreviousPage, sethasPreviousPage] = useState()
    const [hasNextPage, sethasNextPage] = useState()
+     const [departmentList, setDepartmentList] = useState([])//部署データ
   
    useEffect(() => {
        if (lastRecord && list) {
            if (list.length > 0 ){
                sethasNextPage(true)
                list.forEach((item) => {
                    //console.log("useEffect:list.item", item.docId)
                    if ( item.docId === lastRecord.docId){
                        sethasNextPage(false)
                    }
                })
                //最後のレコードを取得する
                setCurrentLastRecord(list.slice(-1)[0]);
            }
        }

        if (firstRecord && list) {
            if (list.length > 0 ){
                sethasPreviousPage(true)
                list.forEach((item) => {
                    //console.log("useEffect:list.item", item.docId)
                    if ( item.docId === firstRecord.docId){
                        sethasPreviousPage(false)
                    }
                })
            }
            //最初のレコードを取得する
            setCurrentFirstRecord(list[0]);

        }
    },[firstRecord,lastRecord,list]);


    useEffect(() => {
         async function fetchData() { // featchDataという関数を定義し、それにasyncをつける
            //最初のページを取得する

-             const colRef = db.collection("members")
-             .orderBy('createdAt', 'desc')
-             .limit(LIMIT_COUNT);
-             console.log(colRef)
-             const snapshots = await colRef.get();
-             var docs = snapshots.docs.map(function (doc) {
-                 return doc.data();
-             });
-             setList(docs)

+             //部署データ
+             const colDepartmentsRef = db.collection("departments")
+             .orderBy('departmentId');

+             //メンバーデータ
+             const colRef = db.collection("members")
+             .orderBy('updatedAt', 'desc')
+             .limit(LIMIT_COUNT);

+             colDepartmentsRef.get()
+             .then((docSnaps) => {
+                 var departments = docSnaps.docs.map(function (doc) {
+                     return doc.data();
+                 });
+                 setDepartmentList(departments)  //部署データを設定する

+                 let departmentName = "";
+                 colRef.get()
+                 .then((docSnaps) => {

+                         departments.forEach(function(department) {
+                             if ( doc.data().departmentId === department.departmentId  ){
+                                 departmentName = department.departmentName;
+                             }
+                         });
+                         return {...doc.data() , departmentName: departmentName};    //memberデータの配列にdepartmentNameを追加するして返す
+                     });
+                     setList(members)
+                 });
+             });

        }

        async function initialData() { // featchDataという関数を定義し、それにasyncをつける
            //最後のレコードをセットする
            await db.collection("members").orderBy('updatedAt', 'desc').limitToLast(1)
            .get()
            .then(function(querySnapshot) {
                querySnapshot.forEach(function(doc) {
                    setLastRecord(doc.data())
                });
            })
            .catch(function(error) {
                console.log("Error getting documents: ", error);
            });
            //最初のレコードをセットする
            await db.collection("members").orderBy('updatedAt', 'desc').limit(1)
            .get()
            .then(function(querySnapshot) {
                querySnapshot.forEach(function(doc) {
                    setFirstRecord(doc.data())
                });
            })
            .catch(function(error) {
                console.log("Error getting documents: ", error);
            });
        }
        initialData();
        fetchData();
    },[inputRef]);

    async function handleNextPage () {  
        //次のページを取得する
        const colRef = db.collection("members")
        .orderBy('updatedAt', 'desc')
        .limit(LIMIT_COUNT)
        .startAfter(currentlastRecord.updatedAt);

        const snapshots = await colRef.get();
+        var docs = snapshots.docs.map(function (doc) {
+             let departmentName = "";
+             departmentList.forEach(function(department) {
+                 if ( doc.data().departmentId === department.departmentId  ){
+                     departmentName = department.departmentName;
+                 }
+             });
+             return {...doc.data() , departmentName: departmentName};    //memberデータの配列にdepartmentNameを追加するして返す


-         var docs = snapshots.docs.map(function (doc) {
-             return doc.data();
-         });
        });
        setList(docs)
    }

    async function handlePreviousPage () {  
        //前のページを取得する
        const colRef = db.collection("members")
        .orderBy('updatedAt')
        .limit(LIMIT_COUNT)
        .startAfter(currentfirstRecord.updatedAt);

        const snapshots = await colRef.get();
+         var docs = snapshots.docs.map(function (doc) {
+             let departmentName = "";
+             departmentList.forEach(function(department) {
+                 if ( doc.data().departmentId === department.departmentId  ){
+                     departmentName = department.departmentName;
+                 }
+             });
+             return {...doc.data() , departmentName: departmentName};    //memberデータの配列にdepartmentNameを追加するして返す
+         });

-         var docs = snapshots.docs.map(function (doc) {
-             return doc.data();
-         });

        //sort処理
        docs.sort(function(a,b){
            if(a.updatedAt>b.updatedAt) return -1;
            if(a.updatedAt < b.updatedAt) return 1;
            return 0;
        });
        setList(docs)

    }

    return (
        <>
            <Typography variant="h4" align="center" component="h1" gutterBottom>
                一覧表示
            </Typography>
            <TableContainer component={Paper}>
                <Table className={classes.table} aria-label="simple table">
                    <TableHead>
                        <TableRow>
                            <TableCell>Email</TableCell>
                            <TableCell>ハンドル名</TableCell>
                            <TableCell>部署名</TableCell>
                            <TableCell>更新日</TableCell>
                            <TableCell>操作</TableCell>
                        </TableRow>
                    </TableHead>
                    <TableBody>
                    {
                    list.map((item, index) => {
                        return (
                            <TableRow key={item.docId}>
                                <TableCell >{item.email}</TableCell>
                                <TableCell >{item.displayName}</TableCell> 
                                <TableCell >{item.departmentName}</TableCell> 

                                <TableCell >
                                  { moment(item.updatedAt.seconds * 1000).format('YYYY-MM-DD HH:mm:ss')}
                                </TableCell> 
                                <TableCell ><Link to={`./edit/${item.docId}`}>編集</Link></TableCell> 
                            </TableRow>
                            )
                        })
                    }
                    </TableBody>
                </Table>
                </TableContainer>
            <div variant="subtitle1" align="center" justify="center" component="h1" >
                {hasPreviousPage && <><Fab component="span" className={classes.button} onClick={handlePreviousPage} ><NavigateBefore className={classes.largeIcon} color={"primary"} /></Fab> </>}
                {!hasPreviousPage && <><Fab component="span" className={classes.button}><NavigateBefore className={classes.largeIcon} color={"disabled"}/></Fab> </>}
                {hasNextPage && <><Fab component="span" className={classes.button}  onClick={handleNextPage}   ><NavigateNext className={classes.largeIcon} color={"primary"} /></Fab></>}
                {!hasNextPage && <><Fab component="span" className={classes.button}><NavigateNext className={classes.largeIcon} color={"disabled"} /></Fab></>}
            </div>
            <Typography className={classes.subtitle2} variant="subtitle2"><Link to="/screens/edit">新規作成</Link></Typography>
            <Typography className={classes.subtitle2} variant="subtitle2"><Link to="/">Homeに戻る</Link></Typography>

        </>

    );
}

プログラム解説1

  • 初期に読み込むデータについてですがベースはmembersにしたいのでベースは最後に読みます。ベースに色付けしていくようなLef touter joinみたいな相手を先に読みます。

今まで

const colRef = db.collection("members")
.orderBy('createdAt', 'desc')
.limit(LIMIT_COUNT);
console.log(colRef)
const snapshots = await colRef.get();
var docs = snapshots.docs.map(function (doc) {
    return doc.data();
});
setList(docs)

修正版

//部署データ
const colDepartmentsRef = db.collection("departments")
.orderBy('departmentId');

//メンバーデータ
const colRef = db.collection("members")
.orderBy('updatedAt', 'desc')
.limit(LIMIT_COUNT);

colDepartmentsRef.get()
.then((docSnaps) => {
    var departments = docSnaps.docs.map(function (doc) {
        return doc.data();
    });
    setDepartmentList(departments)  //部署データを設定する

    let departmentName = "";
    colRef.get()
    .then((docSnaps) => {

            departments.forEach(function(department) {
                if ( doc.data().departmentId === department.departmentId  ){
                    departmentName = department.departmentName;
                }
            });
            return {...doc.data() , departmentName: departmentName};    //memberデータの配列にdepartmentNameを追加
        });
        setList(members)
    });
});

コールバック関数で逃げているのですが、await形式に書き換えたいです。

プログラム解説2

次前ページを取得するロジックでは部署データはある前提なので取得したデータに 部署名を結合しているだけです。

今まで

var docs = snapshots.docs.map(function (doc) {
    return doc.data();
});

修正版

var docs = snapshots.docs.map(function (doc) {
    let departmentName = "";
    departmentList.forEach(function(department) {
        if ( doc.data().departmentId === department.departmentId  ){
             departmentName = department.departmentName;
         }
    });
    return {...doc.data() , departmentName: departmentName};    //memberデータの配列にdepartmentNameを追加するして返す
});

ハマった所

  • データの取得順番なのですが、最初は、メンバー、部署データと取得してuseEffectで書き換えようと思っていたのですが、いざ実際にプログラムを書くと永久にレンダリングするというバグに直面しました。
  • 参考にしたソースには次前の部分がないのでどうやって実装すればいいのかわからず途方に暮れていました。

  • 記載していないのですが、Editの部分も修正しました。そちらもベースになるものの前に読み込みをしないと部署データのセレクトボックスがないのにメンバーの部署IDだけが設定される事が発生して、エラーになりました。読み込みの順番というのが本当に重要だと思う。

  • Material-uiのSelectボックスを使うとWARNINGになりました。

Warning: findDOMNode is deprecated in StrictMode. findDOMNode was passed an instance of Transition which is inside StrictMode. Instead, add a ref directly to the element you want to reference. Learn more about using refs safely here

調べて見ると

非推奨な findDOMNode の使用に対する警告 React ではかつてクラスのインスタンスを元にツリー内の DOM ノードを見つける findDOMNode がサポートされていました。通常、DOM ノードに ref を付与することができるため、このような操作は必要ありません。

findDOMNode はクラスコンポーネントでも使用可能でしたが、これによって親要素が特定の子要素がレンダーされるのを要求する状況が許されてしまい、抽象レベルを破壊してしまっていました。このことにより、親要素が子の DOM ノードにまで踏み込んでしまう可能性があるためにコンポーネントの詳細な実装を変更できない、というようなリファクタリングの危険要因を生み出してしまっていました。findDOMNode は 1 番目の子要素しか返しませんが、フラグメントを使うことによりコンポーネントは複数の DOM ノードをレンダーできます。findDOMNode は 1 回限りの読みこみ API で、問い合わせたときの解答しか返しません。もし子コンポーネントが別のノードをレンダーしていても、この変化を管理することはできません。このため、findDOMNode はコンポーネントが絶対に変化することのない単一の DOM ノードのみを返す場合のみ有効といえます

ja.reactjs.org

という部分に引っかかったのですが・・・どこもおかしい所が見つからず

material-uiの公式サイトのSelectをリファレンスを見たがわからず 公式のサンプルコードをコピペしても同じ結果になる。

公式のcodesandboxを見たら、同じエラーが起きていて「自分のソースがバグっているわけではない」という事にやっと気がついた。公式でもWARNINGなるとか想定していなかった。

最終的にたどり着いたのが

stackoverflow.com

MaterialUIのチームはReact開発者に追いついていない。

バージョンアップで改善されてくれたらいいんだけど・・・使わせてもらっているので何とも言いにくい感じ

結果

感想

  • mongDBですらあるjoinがありませんでした。めんどくさいけど作り方さえわかればギリギリできると思うレベル。

参考URL

www.it-swarm-ja.tech