Skip to content

Instantly share code, notes, and snippets.

@cannikin
Last active November 22, 2021 17:01
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save cannikin/c22309dd3b87d9bb4342910d2c930b5a to your computer and use it in GitHub Desktop.
Save cannikin/c22309dd3b87d9bb4342910d2c930b5a to your computer and use it in GitHub Desktop.
RedwoodRecord: An ORM for Redwood

RedwoodRecord

RedwoodRecord is an ORM (Object-Relational Mapping) for working with database data. Wikipedia says an ORM is for "converting data between incompatible type systems using object-oriented programming languages." In the world of web applications it usually means to a way get data in and out of your database in a way that feels more native to the language you're using, rather than trying to provide just a simple layer on top of raw SQL statements.

This document serves as a Readme Driven Development source for describing the behavior of this system.

Inspiration

It probably comes as no surprise that this idea was heavily inspired by Ruby on Rails. ActiveRecord is the ORM that ships with Rails and is one of its most famous features. It completely abstracts away the datastore and you manipulate data as a regular class instance. Once you learn the syntax it feels like a very natural way to work with your data.

Basic Functionality

The core concept behind an ORM (or at least the core concept behind ActiveRecord) is that there is a class that represents a single table in the database. An instance of this class encapsulates a single row of data in that table. So we might have a User class which is instantiated with the data from a single user in the database:

const user = await User.find(123)
user.email // => 'rob@redwoodjs.com'
user.name  // => 'Rob Cameron'

If you invoke a function that returns multiple records, you get back an array of instances:

const users = await User.where({ company: "Redwood" })
users[0].name  // => 'Rob Cameron'
users[1].name  // => 'Tom Preston-Werner'

When updating an existing record, rather than invoking a database access layer (like Prisma) and giving it all of the data it needs to change, you manipulate the properties on the instance and tell it to save itself:

const user = await User.find(123)
user.email = 'cannikin@redwoodjs.com'
user.save()

Usage Comparison (Prisma vs RedwoodRecord)

Redwood uses Prisma which serves as this thin wrapper around writing SQL statements.

Prisma

The typical Prisma invocations for CRUD operations look like:

import { PrismaClient } from '@prisma/client'
const db = new PrismaClient()

// simple create
const user = await db.user.create({
  data: {
    email: 'elsa@prisma.io',
    name: 'Elsa Prisma',
  },
})

// Select by unique identifier
const user = await prisma.user.findUnique({
  where: {
    email: 'tom@redwoodjs.com',
  },
})

// Select single record by any identifier
const user = await prisma.user.findFirst({
  where: {
    firstName: 'Rob',
  },
})

// Select multiple records by any identifier
const user = await prisma.user.findMany({
  where: {
    company: 'Redwood',
  },
})

// Select all
const user = await prisma.user.findMany()

// Update record
const updateUser = await prisma.user.update({
  where: {
    email: 'viola@prisma.io',
  },
  data: {
    name: 'Viola the Magnificent',
  },
})

// Delete record by unique identifier
const deleteUser = await prisma.user.delete({
  where: {
    email: 'bert@prisma.io',
  },
})

// Delete record by any identifier
const deleteUser = await prisma.user.deleteMany({
  where: {
    company: 'Facebook',
  },
})

RedwoodRecord

// simple create
const user = await User.create({ email: 'elsa@prisma.io', name: 'Elsa Prisma' })

// Select by ID
const user = await User.find(123)

// Select single record by any identifier
const user = await User.first({ email: 'rob@redwoodjs.com' })

// Select all
const users = await User.all()

// Update record
const user = await User.find(123)
user.email = 'cannikin@redwoodjs.com'
await user.save()

// Delete single record
const user = await User.find(123)
user.destroy()

// Delete records by any identifier
await User.delete({ company: 'Meta' })

Relationships

With Prisma you need to include any related data you may ever need access to at the time of the original query. By wrapping Prisma in an ORM we can load related data on the fly when requested:

class User extends RedwoodRecord {
  static hasMany = [Post]
}

class Post extends RedwoodRecord {
  static belongsTo = [User]
}

const user = await User.find(123)
const posts = await user.posts.all()
posts[0].title # => 'Hello, world!'

Relationship Access

In order to maintain features like function chaining, a single record is wrapped in an instance of RedwoodRecord. When accessing a relationship, the relationship itself is represented by an instance of a RelationProxy, which sends further function calls to the actual model for the relationship.

For example, the User model is the "parent" of the Post model. We say that "user has many posts." You can access the posts related to a user through the User model:

const user = await User.find(123) // returns single instance of User
await user.posts.all()            // returns array of instances of Post

Since RedwoodRecord knows how to relate users to posts (a Post.userId points to User.id), the posts that are returned are only those owned by the found user.

This can greatly simplify GraphQL resolvers where more often than not you want to limit access to some records to only those viewable by a logged in user. By setting getCurrentUser() to be an instance of the User model, this functionality comes for free:

// api/src/lib/auth.js
export const getCurrentUser = (session) => {
  return User.find(session.id)
}

// api/src/services/posts/posts.js
export const posts = () => {
  context.currentUser.posts.all()
}

You can also create new posts tied to the currentUser automatically:

// api/src/services/posts/posts.js
export const createPost = ({ input }) => {
  context.currentUser.posts.create(input)
}

Validation

Validation can be very similar to Service Resolvers, where you say which field you want to validate and the rules for that validation:

class User extends RedwoodRecord {
  
  static validates = [{
    'email': { email: true },
    'name': { length: { min: 2, max: 255 } }
  }]
  
  // Remaining implementation...
}

Now if you try to save a record that doesn't pass invalidation, you could simply return false:

const user = await User.find(123)
user.isValid       // => true

user.email = 'invalid'
user.isValid       // => false

await user.save()  // => false
user.errors        // => { email: ['Email is not formatted like an email'] }

user.email = 'rob@redwoodjs.com'
await user.save()  // => user instance

Or throw an error:

const user = await User.find(123)
user.email = 'invalid'
await user.save({ throw: true }) // => Error: Email is not formatted like an email
user.errors                      // => { email: ['Email is not formatted like an email'] }

You could even skip validation if needed:

const user = await User.find(123)
user.email = 'invalid'
await user.save({ validate: false }) // => true
user.errors                          // => {}

Memoization

Making Prisma calls will return to the database each time. Wrapping these calls in RedwoodRecord lets us memoize the result and return that if the same call is made again:

let user = await User.findBy({ email: 'rob@redwoodjs.com' }) // => makes database call
// later in the stack
user = await User.findBy({ email: 'rob@redwoodjs.com' })     // => no database call

You can override this behavior and force a database call if you want to make sure you pick up database changes on rapidly changing data:

let posts = await Post.all()            // => makes database call
// other code
posts = await Post.all({ force: true }) // => makes database call
@jessmartin
Copy link

There is currently a draft pull request here that implements part of this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment