Library system

This example demonstrates how you might model a Library with Single-Table Design principals and ElectroDB.

Table Definition

Example Setup

Table Definition
{
  "TableName": "electro",
  "KeySchema": [
    {
      "AttributeName": "pk",
      "KeyType": "HASH"
    },
    {
      "AttributeName": "sk",
      "KeyType": "RANGE"
    }
  ],
  "AttributeDefinitions": [
    {
      "AttributeName": "pk",
      "AttributeType": "S"
    },
    {
      "AttributeName": "sk",
      "AttributeType": "S"
    },
    {
      "AttributeName": "gsi1pk",
      "AttributeType": "S"
    },
    {
      "AttributeName": "gsi1sk",
      "AttributeType": "S"
    }
  ],
  "GlobalSecondaryIndexes": [
    {
      "IndexName": "gsi1pk-gsi1sk-index",
      "KeySchema": [
        {
          "AttributeName": "gsi1pk",
          "KeyType": "HASH"
        },
        {
          "AttributeName": "gsi1sk",
          "KeyType": "RANGE"
        }
      ],
      "Projection": {
        "ProjectionType": "ALL"
      }
    }
  ],
  "BillingMode": "PAY_PER_REQUEST"
}

Entities

Author

The “author” entity holds information about each author that has a book at the library

const author = new Entity(
  {
    model: {
      entity: "author",
      version: "1",
      service: "library",
    },
    attributes: {
      authorFirstName: {
        type: "string",
        required: true,
      },
      authorLastName: {
        type: "string",
        required: true,
      },
      birthday: {
        type: "string",
      },
      bio: {
        type: "string",
        required: true,
      },
    },
    indexes: {
      writer: {
        pk: {
          field: "pk",
          composite: ["authorLastName"],
        },
        sk: {
          field: "sk",
          composite: ["authorFirstName", "birthday"],
        },
      },
      info: {
        collection: ["works"],
        index: "gsi2pk-gsi2sk-index",
        pk: {
          field: "gsi2pk",
          composite: ["authorLastName", "authorFirstName"],
        },
        sk: {
          field: "gsi2sk",
          composite: [],
        },
      },
    },
  },
  { table },
);

Book

The “book” entity holds represents a physical book at the library because book details (like the author or release date) do not change over time, we can use denormalization to remove the need for a single book entity. This allows us to treat this entity as both an authority on book information and an individual book.

const book = new Entity(
  {
    model: {
      entity: "book",
      version: "1",
      service: "library",
    },
    attributes: {
      bookId: {
        type: "string",
      },
      bookTitle: {
        type: "string",
        required: true,
      },
      description: {
        type: "string",
        required: true,
      },
      publisher: {
        type: "string",
        required: true,
      },
      releaseDate: {
        type: "string",
        required: true,
      },
      authorFirstName: {
        type: "string",
        required: true,
      },
      authorLastName: {
        type: "string",
        required: true,
      },
      isbn: {
        type: "string",
        required: true,
      },
      loanStartDate: {
        type: "string",
      },
      loanEndDate: {
        type: "string",
      },
      memberId: {
        type: "string",
      },
    },
    indexes: {
      copies: {
        collection: ["detail"],
        pk: {
          field: "pk",
          composite: ["isbn"],
        },
        sk: {
          field: "sk",
          composite: ["bookId"],
        },
      },
      loans: {
        collection: ["account"],
        index: "gsi1pk-gsi1sk-index",
        pk: {
          field: "gsi1pk",
          composite: ["memberId"],
        },
        sk: {
          field: "gsi1sk",
          composite: ["loanEndDate"],
        },
      },
      author: {
        collection: ["works"],
        index: "gsi2pk-gsi2sk-index",
        pk: {
          field: "gsi2pk",
          composite: ["authorLastName", "authorFirstName"],
        },
        sk: {
          field: "gsi2sk",
          composite: ["loanEndDate"],
        },
      },
      releases: {
        collection: ["titles"],
        index: "gsi3pk-gsi3sk-index",
        pk: {
          field: "gsi3pk",
          composite: ["bookTitle"],
        },
        sk: {
          field: "gsi3sk",
          composite: ["releaseDate"],
        },
      },
    },
  },
  { table },
);

Genre

The “genre” entity is an instance of a genre, subgenre, and book (via isbn). We can freely associate these with books, adding and removing as needed. Note: we have denormalized the bookTitle onto this record to give context to queries without requiring a lookup to.

const genre = new Entity(
  {
    model: {
      entity: "genre",
      version: "1",
      service: "library",
    },
    attributes: {
      genre: {
        type: "string",
        required: true,
      },
      isbn: {
        type: "string",
      },
      bookTitle: {
        type: "string",
      },
      authorFirstName: {
        type: "string",
        required: true,
      },
      authorLastName: {
        type: "string",
        required: true,
      },
      subgenre: {
        type: "string",
      },
    },
    indexes: {
      book: {
        collection: ["detail"],
        pk: {
          field: "pk",
          composite: ["isbn"],
        },
        sk: {
          field: "sk",
          composite: ["genre", "subgenre"],
        },
      },
      categories: {
        index: "gsi1pk-gsi1sk-index",
        pk: {
          field: "gsi1pk",
          composite: ["genre"],
        },
        sk: {
          field: "gsi1sk",
          composite: ["subgenre"],
        },
      },
      author: {
        collection: ["works"],
        index: "gsi2pk-gsi2sk-index",
        pk: {
          field: "gsi2pk",
          composite: ["authorLastName", "authorFirstName"],
        },
        sk: {
          field: "gsi2sk",
          composite: ["genre"],
        },
      },
      title: {
        collection: ["titles"],
        index: "gsi3pk-gsi3sk-index",
        pk: {
          field: "gsi3pk",
          composite: ["bookTitle"],
        },
        sk: {
          field: "gsi13sk",
          composite: ["genre", "subgenre"],
        },
      },
    },
  },
  { table },
);

Member

The “member” entity represents a single individual library card holding member.

const member = new Entity(
  {
    model: {
      entity: "member",
      version: "1",
      service: "library",
    },
    attributes: {
      memberId: {
        type: "string",
      },
      membershipStartDate: {
        type: "string",
      },
      membershipEndDate: {
        type: "string",
      },
      address: {
        type: "map",
        properties: {
          streetAddress: {
            type: "string",
          },
          city: {
            type: "string",
          },
          state: {
            type: "string",
          },
          zip: {
            type: "string",
          },
        },
      },
    },
    indexes: {
      member: {
        pk: {
          field: "pk",
          composite: ["memberId"],
        },
        sk: {
          field: "sk",
          composite: [],
        },
      },
      _: {
        // this is a duplicate access pattern for the entity
        // but exists to open the door to additional access
        // patterns on the gsi. The 'account' lets you get
        // loans and member information by memberId, but in
        // the future could have other associations by memberId
        // such as "notes", "fees", etc.
        collection: ["account"],
        index: "gsi1pk-gsi1sk-index",
        pk: {
          field: "gsi1pk",
          composite: ["memberId"],
        },
        sk: {
          field: "gsi1sk",
          composite: [],
        },
      },
    },
  },
  { table },
);

Service

Bring all entities into a service

import { Service } from "electrodb";

const library = new Service({
  author,
  book,
  genre,
  member,
});

Access Patterns

Get all copies of books by the authors last name

this same query can be used to get unique books (instead of individual copies) because this entity is denormalized.

const { data, cursor } = await author.query
  .writer({ authorLastName: "king" })
  .go();

Get all authors by last name and a partial first name.

await author.query
  .writer({ authorLastName: "king" })
  .begins({ authorFirstName: "s" })
  .go();

Get all authors by the full name of the writer

await author.query
  .writer({ authorLastName: "smith", authorFirstName: "john" })
  .go();

Get author details, books, and genres

Retrieves author details, books (or available copies), and genres by author full name.

You can then create your own structure using all the returned records

await library.collections
  .works({ authorLastName: "king", authorFirstName: "stephen" })
  .go()
  .then((works) => {
    const [writer] = works.data.author;
    const books = works.data.book;
    const genres = works.data.genre;
    return {
      writer,
      books,
      genres,
    };
  });

Get all copies of a book by isbn

const { data, cursor } = await book.query
  .copies({ isbn: "9783453435773" })
  .go();

Get all the books on loan to a specific member

const { data, cursor } = await book.query.loans({ memberId: "0000001" }).go();

Get all overdue books by memberId

const today = "2022-07-30";

const { data, cursor } = await book.query
  .loans({ memberId: "0000001" })
  .gt({ loanEndDate: today })
  .go();

Get number of books checked out by user

const count = await book.query
  .loans({ memberId: "0000001" })
  .go()
  .then((loans) => loans.data.length);

Get member information and their checked out books

const results = await library.collections
  .account({ memberId: "0000001" })
  .go()
  .then((result) => {
    const [member] = result.data.member;
    const books = result.data.book;
    return {
      member,
      books,
    };
  });

Get all books/copies by an author’s full name.

This is a duplicate access pattern for this entity, but it allows for additional access patterns, including a cross-entity collection

const { data, cursor } = await book.query
  .author({ authorLastName: "king", authorFirstName: "stephen" })
  .go();

Get all copies available to be checked out.

Our loanEndDate property either holds the date the book is due or the text ‘AVAILABLE’

const BOOK_IS_AVAILABLE = "AVAILABLE";

const { data, cursor } = await book.query
  .author({
    authorLastName: "king",
    authorFirstName: "stephen",
    loanEndDate: BOOK_IS_AVAILABLE,
  })
  .go();

Get all copies NOT available to be checked out.

If a copy is checked out the loanEndDate value will be a date value. Date values have a format: YYYY-MM-DD which will sort before ‘AVAILABLE’, so any copy with a loanEndDate < ‘AVAILABLE’ will be checked out

const BOOK_IS_AVAILABLE = "AVAILABLE";

const { data, cursor } = await book.query
  .author({
    authorLastName: "king",
    authorFirstName: "stephen",
  })
  .lt({ loanEndDate: BOOK_IS_AVAILABLE })
  .go();

Get books/copies by title

const { data, cursor } = await book.query.releases({ bookTitle: "it" }).go();

Get books/copies by title and partial release date

Queries can be provided partially like just the year, the year and month, etc.

book.query.releases({ bookTitle: "it" }).gte({ releaseDate: "1990" }).go();

book.query
  .releases({ bookTitle: "it" })
  .between({ releaseDate: "1990" }, { releaseDate: "2019" })
  .go();

Get the genres and subgenres associated with a book

await genre.query.book({ isbn: "9783453435773" }).go();
await genre.query.title({ bookTitle: "it" }).go();

Get the sub-genres associated with a book and one of its main genres

await genre.query.book({ isbn: "9783453435773", genre: "horror" }).go();
await genre.query.title({ bookTitle: "it", genre: "horror" }).go();

Get a book and its genres by isbn or book title

await library.collections.detail({ isbn: "9783453435773" }).go();
await library.collections.titles({ bookTitle: "it" }).go();

Get books within a genre

const { data, cursor } = await genre.query.categories({ genre: "horror" }).go();

Get books within a genre and subgenre

const { data, cursor } = await genre.query
  .categories({ genre: "horror", subgenre: "killer clowns" })
  .go();

Get genres by a given author full name

await genre.query
  .author({ authorFirstName: "stephen", authorLastName: "king" })
  .go()
  .then((results) => {
    const uniqueGenres = new Set<string>();
    for (const { genre } of results.data) {
      uniqueGenres.add(genre);
    }
    return Array.from(uniqueGenres);
  });

Get subgenres within a given author and genre

const { data, cursor } = await genre.query
  .author({
    authorFirstName: "stephen",
    authorLastName: "king",
    genre: "horror",
  })
  .go();

Get member information by memberId

const { data, cursor } = await member.query
  .member({ memberId: "0000001" })
  .go();