Help with Relations, Pointers, and Database Organization in Moralis DB?

Hi Moralis Community,

I have a quick question for you! Is there anyone at Moralis that can help me learn about Relational Databases and how Moralis Objects work w/ users?

My question is:

How can I associate Classes with each other? I was looking at Pointers and Relations but it’s a bit abstract to me.

  • For example, how does a Book (object) get it’s ISBD (Relation) associated with it.
  • Another one is how to associate 1 or more authors to a book (one-to-many relationship array).
  • A further question is, how can I make the books ownable by only one User (object) at a time, but the user can own many books (one-to-many relationship array).

Context:

I’m working on a :books: Library App for practice on databases. So each book object gets it’s objectId in the Book Class (I get that and you can create a book)…but if I wanted to make the Books ownable by a certain user in the database, that’s what I’m a bit stuck on.

:white_check_mark: This works: For Pointers, what I am doing is creating an ISBD class that makes it where each book must have a unique ISBD number, or else the book cannot be created.

:x: What doesn’t work: For Relations, what I am trying to do is create a way where many authors can be assigned to a book (i.e. as a selection), and allow many books to be “ownable” by a given User - so you could have 2 books, or 1 book, or 10 books, but I couldn’t have any of those books in your “ownableBooks” class at the same time you are reviewing them.

Book Class:

Just a simple class with properties about the book. You will see there is a available field which is a boolean that someone could click and that will take the book out. What I would love to do is wrap this into a function where as a user “takes out a book” - it goes into their ownableBooks array and then that book’s “availability” is set to false until the user puts it back.

:white_check_mark: ISBD Class: this works as a pointer param for the class Books

When you try to create a book with the same ISBD that exists in the database, it won’t allow it. This is a great use-case for filtering out inputs if a certain property is the same as other objects in the db.

:x: The main issue: my Authors relationship doesn’t work

It comes up as empty when I click on the View relation for authorsTest in the Book class screenshot (below). Perhaps something is wrong in my code (further below) and my relationship isn’t getting established correctly?

I believe I want to have something like this below, but I’m confused how the Authors would be “assigned” to the Books class so one book could have 1 or more authors.

:x: The other issue: Users can’t reserve their own books

I’m hoping to be able to have an array of the books each user has, but I cannot seem to figure out how an objectId can be assigned to only one user programmatically in-app. For example, there are 10 books in the database…you take out 2, there are only 8 books left and no one can take out those 2 books until you put them back.

+++

The App: (Code is below)


My Code: NEXTjs x TypeScript

import React, { useState, FC, ReactElement, useEffect } from 'react';
import styles from './App.module.css';
import { Button, Checkbox, Divider, Input, Radio, List } from 'antd';
import { useMoralis, useMoralisQuery } from "react-moralis";
import Router , { useRouter } from 'next/router';
import Moralis from 'moralis/types';
import {
  CheckOutlined,
  CloseOutlined,
  PlusOutlined,
  RedoOutlined,
  PullRequestOutlined
} from '@ant-design/icons';


export const BookRegistrationApp: FC<{}> = (): ReactElement => {
  const { 
    isAuthenticated, 
    user, 
    setUserData, 
    userError, 
    isUserUpdating,
    refetchUserData,
    Moralis,
  } = useMoralis();
  
  // State variables
  const initialMoralisObjectArray: Moralis.Object[] = [];
  const [publishers, setPublishers] = useState<Moralis.Object[] | null>(null);
  const [authors, setAuthors] = useState<Moralis.Object[] | null>(null);
  const [genres, setGenres] = useState<Moralis.Object[] | null>(null);
  const [isbds, setIsbds] = useState<Moralis.Object[] | null>(null);

  const [bookTitle, setBookTitle] = useState('');
  const [bookYear, setBookYear] = useState('');
  const [bookISBD, setBookISBD] = useState('');
  const [bookPublisherInput, setBookPublisherInput] = useState('');
  const [bookGenreInput, setBookGenreInput] = useState('');
  const [bookAuthorInput, setBookAuthorInput] = useState('');

  const [bookPublisher, setBookPublisher] = useState<Moralis.Object>();
  const [bookGenre, setBookGenre] = useState<Moralis.Object>();
  const [bookAuthors, setBookAuthors] = useState(initialMoralisObjectArray);


  // ============================================== //
  //           METHOD #1: QUERY/FETCH DATA
  // ============================================== //
  
  // useEffect is called after the component is initially rendered and after every other render
  useEffect(() => {
    async function getFormChoices(): Promise<boolean> {
      // This condition ensures data is updated only if needed
      if (
        publishers === null && 
        authors === null && 
        genres === null
      ) {
          // Query all choices
          for (let choiceObject of [
            'Publisher', 
            'Author', 
            'Genre'
          ]) {
            let newQuery: Moralis.Query = new Moralis.Query(choiceObject);
            try {
              let queryResults: Moralis.Object[] = await newQuery.find();
              // Empty or invalid queries return as an empty array
              // Set results to state variable
              if (choiceObject === 'Publisher') {
                setPublishers(queryResults);
              } else if (choiceObject === 'Author') {
                setAuthors(queryResults);
              } else if (choiceObject === 'Genre') {
                setGenres(queryResults);
              } 
            } catch (error: any) {
              // Error can be caused by lack of Internet Connection
              alert(`Error! ${error.message}`);
              return false;
            }
          }
        }
        return true;
    }
    getFormChoices();
  }, [publishers, authors, genres]);



  const fetchBooks = async function (): Promise<boolean> {
    // Reading parse objects is done by using Moralis.Query
    const moralisQuery: Moralis.Query = new Moralis.Query('Book');
    try {
      let books: Moralis.Object[] = await moralisQuery.find();
      // Be aware that empty or invalid queries return as an empty array
      // Set results to state variable
      setBookAuthors(books);
      return true;
    } catch (error: any) {
      // Error can be caused by lack of Internet connection
      alert('Error!' + error.message);
      return false;
    }
  };


  // ============================================== //
  //      METHOD #2: BOOK CREATION FUNCTIONS
  // ============================================== //

  const createBook = async function(): Promise<boolean> {
    try {
      // these values come from state variables linked to screen form fields, retrieving the user choices as a complete Moralis.Object, when applicable;
      const bookTitleValue: string = bookTitle;
      const bookYearValue: number = Number(bookYear);
      const bookISBDValue: string = bookISBD;
      
      // Example; bookPublisher holds value from RadioButton.Group field with its options being every Publisher Moralis Object instance saved on server, which is queried on screen load via useEffect
      const bookPublisherObject: Moralis.Object | undefined = bookPublisher;
      const bookGenreObject: Moralis.Object | undefined = bookGenre;
      
      // bookAuthors can be an array of Moralis.Objects, since the book may have more than one Author
      const bookAuthorsObjects: Moralis.Object[] = bookAuthors;

      // Creates a new Moralis Object Instance
      let Book: Moralis.Object = new Moralis.Object('Book');

      // Set data to Moralis Object
      // Title field here:
      Book.set('title', bookTitleValue);
      // Simple number field
      Book.set('year', bookYearValue);
      // Set the book as automatically available
      Book.set('available', true);

      // ONE-TO-ONE (1:1)
      // Check uniqueness of value before creating new ISBD object
      let isbdQuery: Moralis.Query = new Moralis.Query('ISBD');
      isbdQuery.equalTo('name', bookISBDValue);
      let isbdQueryResult: Moralis.Object | undefined = await isbdQuery.first();
      if (isbdQueryResult !== null && isbdQueryResult !== undefined) {
          // if first returns a valid object instance, then means that there is at least one instance of ISBD w/ informed value
          alert(
            'Error! There is already an ISBD instance with this value!',
          );
          return false;
        } else {
          // Create a new ISBD object instance to create a 1:1 relation on saving
          let ISBD: Moralis.Object = new Moralis.Object('ISBD');
          ISBD.set('name', bookISBDValue);
          ISBD = await ISBD.save();
          // set the new object to the new book object ISBD field
          Book.set('isbd', ISBD);
      }

      // ONE-TO-MANY (1:+)
      // add direct object to field (Moralis will convert to pointer on save)
      Book.set('publisher', bookPublisherObject);
      // or add pointer to field
      if (bookGenreObject !== undefined) {
        Book.set('genre', bookGenreObject.toPointer());
      }

      // MANY-TO-MANY (+:+)
      // create a new relation so data can be added
      let authorsRelation: Moralis.Relation = Book.relation('authors');
      // bookAuthorsObjects is an array of Moralis.Objects
      // you can add to relation by adding the whole array or object by object
      authorsRelation.add(bookAuthorsObjects);

      // After setting values, save it on the server
      try {
        await Book.save();
        // Success
        alert('Success!');
        Router.push('/04-relationships');
        return true;
      } catch (error: any) {
        // Error can be caused by lack of Internet connection
        alert(`Error! ${error.message}`);
        return false;
      }
    } catch (error: any) {
      // Error can be caused by lack of Internet connection
      alert(`Error! ${error}`);
      return false;
    }
  };

  const reserveBook = async function (bookId: string, available: boolean): Promise<boolean> {
    // Create a new to-do parse object instance and set todo id
    let Book: Moralis.Object = new Moralis.Object('Book');
    Book.set('objectId', bookId);
    // Set new done value and save Moralis Object changes
    Book.set('available', available);
    try {
      await Book.save();
      // Success
      alert('Success! Book checked-out!');
      // Refresh todos list
      fetchBooks();
      return true;
    } catch (error: any) {
      // Error can be caused by lack of Internet connection
      alert('Error!' + error.message);
      return false;
    }
  };

  const handlePressCheckboxAuthor = (author: Moralis.Object) => {
    if (bookAuthors.includes(author)) {
      setBookAuthors(bookAuthors.filter((bookAuthor) => bookAuthor !== author));
    } else {
      setBookAuthors(bookAuthors.concat([author]));
    }
  } 

  return (
    <div>
      <div className={styles.header}>
        <p className={styles.header_text_bold}>{'React on Moralis'}</p>
        <p className={styles.header_text}>{'React Relations'}</p>
      </div>
      <div className={styles.container}>
        <h2 className={styles.heading}>New Book</h2>
        <Divider/>
        <div 
          style={{
            display: "inline-grid",
            width: "100%"

        }}
        >
        <Input 
          className={styles.form_input}
          value={bookTitle}
          onChange={(event) => setBookTitle(event.target.value)}
          placeholder="Title"
          size="large"
        />

        <Input 
          className={styles.form_input}
          value={bookYear}
          onChange={(event) => setBookYear(event.target.value)}
          placeholder="Publishing Year"
          size="large"
        />

        <Input 
          className={styles.form_input}
          value={bookISBD}
          onChange={(event) => setBookISBD(event.target.value)}
          placeholder="ISBD"
          size="large"
        />

        <Input
          className={styles.form_input}
          value={bookPublisherInput}
          onChange={(event) => setBookPublisherInput(event.target.value)}
          placeholder="Publisher"
          size="large"
        />

        <Input
          className={styles.form_input}
          value={bookGenreInput}
          onChange={(event) => setBookGenreInput(event.target.value)}
          placeholder="Genres (separated by comma)"
          size="large"
        />

        <Input
          className={styles.form_input}
          value={bookAuthorInput}
          onChange={(event) => setBookAuthorInput(event.target.value)}
          placeholder="Authors (separated by comma)"
          size="large"
        />

        </div>
        {publishers !== null && (
          <>
            <h3 className={styles.subheading}>Publisher</h3>
            <Radio.Group 
              onChange={(event) => setBookPublisher(event.target.value)}
              value={bookPublisher}
            >
              <div>
                {publishers.map((publisher: Moralis.Object, index: number) => (
                  <Radio 
                    key={`${index}`}
                    value={publisher}>
                      {publisher.get('name')}
                  </Radio>
                ))}
              </div>
            </Radio.Group>
          </>
        )}
        {genres !== null && (
          <>
            <h3 className={styles.subheading}>Genres</h3>
            <Radio.Group 
              onChange={(event) => setBookGenre(event.target.value)}
              value={bookGenre}
            >
              <div>
                {genres.map((genre: Moralis.Object, index: number) => (
                  <Radio 
                    key={`${index}`}
                    value={genre}
                  >
                    {genre.get('name')}
                  </Radio>
                ))}
              </div>
            </Radio.Group>
          </>
        )}
        {authors !== null && (
          <>
            <h3 className={styles.subheading}>Author(s)</h3>
            <>
              {authors.map((author: Moralis.Object, index: number) => (
                <div 
                  key={`${index}`} 
                  className={styles.checkbox_item}
                >
                  <span className={styles.checkbox_text}>{author.get('name')}</span>
                  <Checkbox 
                    onChange={(_e) => handlePressCheckboxAuthor(author)}
                    checked={bookAuthors.includes(author)}>

                    </Checkbox>
                </div>
              ))}
              {/* {authors.length < 1 && (
                <div 
                  className={styles.checkbox_item}
                >
                   <span className={styles.checkbox_text}>hello2</span>
                   <Checkbox 
                    onChange={(event) => setBookAuthors(event.target.value)}
                    >
                    </Checkbox>
                 
                </div>
              )} */}
            </>
          </>
        )}
        <div className={styles.form_buttons}>
          <Button 
            onClick={() => createBook()}
            type="primary"
            className={styles.form_button}
            icon={<PlusOutlined /> }
            size="large"
            style={{
              background: "#208AEC",
              outline: "none",
              height: 30,
              border: "none",
              color: "white",
              fontWeight: "10",
              fontSize: "12pt"
            }}>
              CREATE BOOK
            </Button>
        </div>

      </div>
      <div className={styles.container}>
        <div style={{display: "flex"}}>
          <h2 
            className={styles.heading}
            style={{
              position: "relative",
              float: "left"
            }}
            >
              Results
          </h2>
          <div 
            style={{
              top: "50%",
              transform: "translateY(30%)",
              marginLeft: "20px"
            }}
          >
            <button onClick={fetchBooks}>Refresh</button>
            {console.log(bookAuthors)}

          </div>
        </div>
        <Divider/>
        <div 
          style={{
            display: "inline-grid",
            width: "100%"
        }}
        >
          {bookAuthors !== null &&
            bookAuthors !== undefined &&
            bookAuthors.length > 0 && (
              <List
                dataSource={bookAuthors}
                renderItem={(item: Moralis.Object) => (
                  <List.Item className={styles.todo_item}>
                    
                    <h2
                      className={
                        item.get('available') === false
                          ? `${styles.todo_text_done}`
                          : `${styles.todo_text}`
                      }
                    >
                      {/* Fetch the Title */}
                      <b>Title: </b>{item.get('title')}
                    </h2>

                    <p
                      className={
                        item.get('available') === false
                          ? `${styles.todo_text_done}`
                          : `${styles.todo_text}`
                      }
                    >
                      <b>ISBD #: </b>
                      {/* {item?.get('isbd')} */}
                      {console.log(item.attributes.isbd.id)}
                      {console.log(item.attributes.isbd)}
                    </p>

                    <p
                      className={
                        item.get('available') === false
                          ? `${styles.todo_text_done}`
                          : `${styles.todo_text}`
                      }
                    >
                      {/* Fetch the Date */}
                      {/* {JSON.stringify(item?.updatedAt.getMonth())} 
                      {'/'}
                      {JSON.stringify(item?.updatedAt.getDate())}
                      {'/'}
                      {JSON.stringify(item?.updatedAt.getFullYear())} */}
                      <b>Last updated: </b>{item?.updatedAt.toLocaleDateString()}
                      {/* {console.log(item?.updatedAt)} */}
                    </p>
                    <div className={styles.flex_row}>
                      {/* Todo update button */}
                      
                      {item.get('available') !== true && (
                        <Button
                          type="primary"
                          shape="circle"
                          className={styles.todo_button}
                          onClick={() => reserveBook(item.id, true)}
                          icon={
                            <PullRequestOutlined className={styles.todo_button_icon_done} />
                          }
                        > Request from Library</Button>
                      )}

                      {item.get('available') !== false && (
                        <Button
                          type="primary"
                          shape="circle"
                          className={styles.todo_button}
                          onClick={() => reserveBook(item.id, false)}
                          icon={
                            <CheckOutlined className={styles.todo_button_icon_done} />
                          }
                        >Take Out Book</Button>
                      )}
                      {/* Todo delete button */}
                      
                    </div>
                  </List.Item>
                )}
              />
            )}
          
        </div>
      </div>
      
    </div>
  );
};

export default BookRegistrationApp;

you may find out more examples/explanations on google by searching how they work with parse server.

in particular you can also look directly in mongo db to see how the data is structured:
https://docs.moralis.io/moralis-dapp/database/direct_access

I haven’t had much luck searching on-line unfortunately. Is there an applicable database design that’s the same that I could find more stuff out about?

For example, is Parse similar enough to MongoDB where learning it would help in this case?

Or do you know of any resources for learning this? I’m running out of options of places to look online lol

it is the same syntax as it is on parse server, as Moralis Server user parse server, and in particular it uses mongo db now as the backend database

Maybe I need to go through this more, Parse’s JavaScript Guide?

Or what do you think would be a good place to learn more?

I’m surprised there isn’t more resources on _User class and objects.

that documentation could help you

can you summarise in a sentence what is the problem that you have?

Definitely! I want to be able to allow individual users (_User class) to create Book objects and when the _User creates a Book, it goes into that user’s listOfBooks array as a one-to-many relationship.

Is this helpful @cryptokid ?

it sounds like creating the Book object is easy

then you have to add it to a relation, not sure what you mean by that array
if you want to maintain an array then you can maintain it directly maybe without a relation

Yea but is there a reason why the accounts for a user is just an array of ethAddress strings (1st screenshot - second to last column) vs. the ethAddress prop which is JUST a string.

Then, in the _EthAddress class (2nd screenshot)…there is a pointer mapping the addresses to each _User

So is this a 1:many relationship between a user and their ethAddress’s? I guess I’m just trying to understand how in the 2nd screenshot of _EthAddress, there is a Pointer established to the User, but doesn’t have a pointer, just an Array of those address items.

_User class

_EthAddress class:

who doesn’t have a pointer? who has an array of address items?

All users have a pointer to their signature and EthAddress: 0xb48bc345ge67fd824160y6f034c9c7d435k, but there is an accounts Array:

[
  "0xb48bc345ge67fd824160y6f034c9c7d435k"
]

Each user’s objectId then is mapped with the pointer to the _EthAddress class.

If I can learn how this is done, maybe I can learn how the pointers/relations are set up for one-to-one relationships and one-to-many relationships.

Just trying to think of examples IRL that can map to the use-case here.

A pointer as how is seen in parse server is a pointer to another row/object, as it is using in _EthAddress a pointer to a User object.
The other ones are fields directly as string or arrays of strings, in this case extra logic has to be added to extract the information (when using strings or array of strings), with a pointer you can get the information related to the pointer in a single query.