DEV Community

Chinwuba
Chinwuba

Posted on

Understanding Prisma and Sql — Relationships, Schema, and Querying

Part 1 — Understanding Relationships in the Schema

Before you can query, you need to understand what the schema is actually saying. Prisma uses a specific syntax to define how models relate to each other.

One-to-Many

One user has many posts. One post belongs to one user.

model User {
  id    String @id @default(cuid())
  name  String
  posts Post[]           // <-- User has many posts (array = many)
}

model Post {
  id       String @id @default(cuid())
  content  String
  author   User   @relation(fields: [authorId], references: [id])  // <-- Post belongs to one User

  authorId String // <-- This is the foreign key stored in the DB
}
Enter fullscreen mode Exit fullscreen mode

What to understand here:

Post stores the foreign key (authorId) — that's always where @relation lives, on the model that "owns" the foreign key. User just has posts Post[] as a reference back, it stores nothing extra in the database for that.

When you write @relation(fields: [authorId], references: [id]) you're saying: the authorId column in this table points to the id column in the User table.


Many-to-Many (via a junction table)

One user can be a member of many projects. One project can have many members. You need a table in between.

model User {
  id          String          @id @default(cuid())
  memberships ProjectMember[]  // User has many memberships
}

model Project {
  id      String          @id @default(cuid())
  members ProjectMember[]  // Project has many memberships
}

model ProjectMember {
  id        String  @id @default(cuid())
  role      String  @default("MEMBER")

  user      User    @relation(fields: [userId], references: [id])
  userId    String

  project   Project @relation(fields: [projectId], references: [id])
  projectId String

  @@unique([userId, projectId])  // A user can only appear once per project
}
Enter fullscreen mode Exit fullscreen mode

ProjectMember is the junction table. It holds foreign keys to both sides and can carry its own data (like role). @@unique([userId, projectId]) is a composite unique constraint — it means the combination of both columns must be unique. A user can appear multiple times in the table, but never twice for the same project.

Self-Referential (a model relating to itself)

This is what Follow does in a social media platform — a User follows another User.

model User {
   id String  @id @default(cuid())
  followers Follow[] @relation("Following")  // People who follow this user
  following Follow[] @relation("Follower")   // People this user follows
}

model Follow {
  id          String @id @default(cuid())

  follower    User   @relation("Follower", fields: [followerId], references: [id])
  followerId  String

  following   User   @relation("Following", fields: [followingId], references: [id])
  followingId String

  @@unique([followerId, followingId])
}
Enter fullscreen mode Exit fullscreen mode

Because both sides point to User, Prisma can't figure out which is which without named relations. "Follower" and "Following" are just labels that let Prisma match up which field on User maps to which side of Follow. The names themselves don't matter, they just have to match between the model and the junction table.

Named Relations (Two Relations to the Same Model)

Same problem occurs in a Task model — a task has an assignedTo user and a createdBy user. Both point to User, so both need names:

model Task {
  assignedTo   User?   @relation("AssignedTo", fields: [assignedToId], references: [id])
  assignedToId String?

  createdBy    User    @relation("CreatedBy", fields: [createdById], references: [id])
  createdById  String
}

model User {
  assignedTasks Task[] @relation("AssignedTo")
  createdTasks  Task[] @relation("CreatedBy")
}
Enter fullscreen mode Exit fullscreen mode

The question mark in the assignedTo user is to show that it's optional.
Rule: any time two fields in the same model point to the same other model, both need a name. Prisma will throw an error at migration time if you forget.

Part 2 — Reading Data (Queries)

Every read in Prisma starts with one of these:

Method When to use it
findMany Get a list of records
findUnique Get exactly one record by a unique field
findFirst Get the first record matching a filter

findUnique

Used when you're looking up by a field marked @id or @unique in the schema.

// Get user by id
const user = await prisma.user.findUnique({
  where: { id: 'some-id' }
})

// Get user by email (also @unique)
const user = await prisma.user.findUnique({
  where: { email: 'jeffrey@velto.com' }
})

// Composite unique — ProjectMember has @@unique([userId, projectId])
const membership = await prisma.projectMember.findUnique({
  where: {
    userId_projectId: {   // <-- Prisma generates this name from the field names
      userId: 'abc',
      projectId: 'xyz'
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

The composite unique key name is generated by joining the field names with underscores. @@unique([userId, projectId]) becomes userId_projectId. @@unique([postId, userId]) on Like becomes postId_userId.

findUnique returns null if nothing is found, it doesn't throw.


findMany

Gets a list. By default it returns everything. You narrow it with filters.

// All projects
const projects = await prisma.project.findMany()

// All projects ordered by name
const projects = await prisma.project.findMany({
  orderBy: { name: 'asc' }  // 'asc' or 'desc'
})

// Latest 20 posts
const posts = await prisma.post.findMany({
  orderBy: { createdAt: 'desc' },
  take: 20
})

// Posts 21-40 (pagination)
const posts = await prisma.post.findMany({
  orderBy: { createdAt: 'desc' },
  take: 20,
  skip: 20
})
Enter fullscreen mode Exit fullscreen mode

findFirst

Like findMany but returns only the first match. Useful when you need one record but can't use findUnique because you're not filtering by a unique field.

const task = await prisma.task.findFirst({
  where: { title: 'Fix the login bug' }
})
Enter fullscreen mode Exit fullscreen mode

Part 3 — where

where is how you filter. It maps 1:1 to SQL's WHERE clause.


Basic equality

const users = await prisma.user.findMany({
  where: { role: 'ADMIN' }
})
Enter fullscreen mode Exit fullscreen mode

this returns all records with role ADMIN

Operators

Prisma wraps operators in objects:

where: {
  price: { gt: 50 }        // greater than
  price: { gte: 50 }       // greater than or equal
  price: { lt: 100 }       // less than
  price: { lte: 100 }      // less than or equal
  price: { not: 50 }       // not equal
  name: { contains: 'shoe' }             // SQL LIKE '%shoe%'
  name: { contains: 'shoe', mode: 'insensitive' }  // case-insensitive
  name: { startsWith: 'Nike' }
  name: { endsWith: 'Pro' }
  id: { in: ['id1', 'id2', 'id3'] }     // SQL IN
  id: { notIn: ['id1', 'id2'] }
}
Enter fullscreen mode Exit fullscreen mode

AND, OR, NOT

// Both conditions must be true (AND is the default when you pass multiple fields)
where: {
  category: 'Electronics',
  price: { lte: 100 }
}

// Explicit OR — either condition matches
where: {
  OR: [
    { name: { contains: 'shoe', mode: 'insensitive' } },
    { description: { contains: 'shoe', mode: 'insensitive' } }
  ]
}

// NOT
where: {
  NOT: { role: 'ADMIN' }
}

// Combining them
where: {
  AND: [
    { category: 'Electronics' },
    {
      OR: [
        { name: { contains: 'phone' } },
        { name: { contains: 'laptop' } }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Relation Filters — some, every, none

These are used inside where when you want to filter by properties of a related model.

// Get all projects where the current user is a member
const projects = await prisma.project.findMany({
  where: {
    members: {
      some: { userId: req.user.id }
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

some means "at least one related record matches this condition." This is the most common one.

// some — at least one member has role OWNER
where: { members: { some: { role: 'OWNER' } } }

// every — ALL members have role OWNER (rarely needed)
where: { members: { every: { role: 'OWNER' } } }

// none — NO members have role OWNER
where: { members: { none: { role: 'OWNER' } } }
Enter fullscreen mode Exit fullscreen mode

The feed query in a social media platform uses a nested some:

// Posts from users the logged-in user follows
where: {
  author: {
    followers: {
      some: { followerId: req.user.id }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Breaking this down: you're filtering Post where the author (a User) has followers (a list of Follow records) where at least one has followerId matching the current user. That's a three-level deep filter in one where.


Conditional where (dynamic filters)

This pattern shows up in the products search route:

const { category, search } = req.query

const products = await prisma.product.findMany({
  where: {
    ...(category && { category }),           // Only add if category is provided
    ...(search && {
      OR: [
        { name: { contains: search, mode: 'insensitive' } },
        { description: { contains: search, mode: 'insensitive' } }
      ]
    })
  }
})
Enter fullscreen mode Exit fullscreen mode

The spread ...(condition && { key: value }) is a JavaScript pattern — if category is undefined or empty, the spread adds nothing. If it has a value, it spreads { category: 'Electronics' } into the where object.


Part 4 — select

select lets you choose exactly which fields to return. Everything not listed is excluded.

// Only get id, name, email — not password, not createdAt
const user = await prisma.user.findUnique({
  where: { id: userId },
  select: {
    id: true,
    name: true,
    email: true
  }
})
Enter fullscreen mode Exit fullscreen mode

You cannot use select and include at the same level at the same time. You pick one.

But you CAN use select inside an include:

const tasks = await prisma.task.findMany({
  include: {
    assignedTo: {
      select: { id: true, name: true }  // Only get id and name of the user, not their password etc.
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

This is the correct pattern for pulling in related data without exposing sensitive fields.


Part 5 — include

include tells Prisma to JOIN related data and return it nested in the result. Without it, relations just don't come back.

// Without include — you just get the task fields
const task = await prisma.task.findUnique({ where: { id } })
// Result: { id, title, description, boardId, assignedToId, createdById, ... }
// No user data, no board data, no comments

// With include
const task = await prisma.task.findUnique({
  where: { id },
  include: {
    assignedTo: true,   // Include the full User object
    board: true,        // Include the full Board object
    comments: true      // Include all Comment objects in an array
  }
})
// Result: { id, title, ..., assignedTo: { id, name, email, ... }, board: { ... }, comments: [...] }
Enter fullscreen mode Exit fullscreen mode

Nested include

You can include inside an include:

const project = await prisma.project.findUnique({
  where: { id },
  include: {
    boards: {
      orderBy: { order: 'asc' },
      include: {
        tasks: {
          include: {
            assignedTo: {
              select: { id: true, name: true }
            }
          }
        }
      }
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

This gives you: project → boards (sorted) → tasks → assignedTo (just id and name). Three levels deep in one query.


Filtering inside include

You can put a where inside an include to filter the related records:

// Only include memberships where the user is an OWNER
include: {
  members: {
    where: { role: 'OWNER' }
  }
}

// The isFollowing trick from a social media platform — only include the Follow record if it belongs to the current user
include: {
  likes: {
    where: { userId: req.user.id }
  }
}
// If the current user liked this post, likes array has 1 item. If not, it's empty [].
// So isLiked = likes.length > 0
Enter fullscreen mode Exit fullscreen mode

Part 6 — _count

_count gives you the count of related records without fetching them all. It's like doing a COUNT(*) in SQL without loading the actual rows.

const projects = await prisma.project.findMany({
  include: {
    _count: {
      select: {
        members: true,  // Count how many members
        boards: true    // Count how many boards
      }
    }
  }
})

// Result: { id, name, ..., _count: { members: 4, boards: 3 } }
Enter fullscreen mode Exit fullscreen mode

You can combine _count with other includes:

include: {
  _count: { select: { likes: true, comments: true } },
  assignedTo: { select: { id: true, name: true } },
  comments: true
}
Enter fullscreen mode Exit fullscreen mode

Part 7 — Writing Data

create

const user = await prisma.user.create({
  data: {
    name: 'Jeffrey',
    email: 'jeffrey@velto.com',
    password: hashedPassword
  }
})
Enter fullscreen mode Exit fullscreen mode

You can create related records inline (nested writes):

// Create a project and immediately create its boards in one call
const project = await prisma.project.create({
  data: {
    name: 'Velto Website Redesign',
    boards: {
      create: [
        { name: 'To Do', order: 0 },
        { name: 'In Progress', order: 1 },
        { name: 'Done', order: 2 }
      ]
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

createMany

Creates multiple records in one database call:

await prisma.board.createMany({
  data: [
    { name: 'To Do', order: 0, projectId: project.id },
    { name: 'In Progress', order: 1, projectId: project.id },
    { name: 'Done', order: 2, projectId: project.id }
  ]
})
Enter fullscreen mode Exit fullscreen mode

createMany doesn't support nested creates or return the created records by default. Use create with nested writes if you need that.

update

// Update a single record by unique field
await prisma.task.update({
  where: { id: taskId },
  data: { title: 'New title' }
})
Enter fullscreen mode Exit fullscreen mode

Partial updates — only update what you send:

// Only update fields the client sent, ignore undefined ones
const { title, description, priority, boardId } = req.body

await prisma.task.update({
  where: { id },
  data: {
    ...(title !== undefined && { title }),
    ...(description !== undefined && { description }),
    ...(priority !== undefined && { priority }),
    ...(boardId !== undefined && { boardId })
  }
})
Enter fullscreen mode Exit fullscreen mode

Atomic operations (increment/decrement without a read-then-write):

await prisma.product.update({
  where: { id: productId },
  data: { stock: { decrement: quantity } }
})

// Also available: increment, multiply, divide, set
Enter fullscreen mode Exit fullscreen mode

updateMany

Updates all records matching a filter:

// Mark all notifications as read for this user
await prisma.notification.updateMany({
  where: { userId: req.user.id, read: false },
  data: { read: true }
})
Enter fullscreen mode Exit fullscreen mode

updateMany returns { count: N } — the number of records updated.

upsert

Either creates or updates depending on whether the record exists. Takes three fields: where, update, create.

// Add to cart — if the user already has this product, increment quantity
const cartItem = await prisma.cartItem.upsert({
  where: {
    userId_productId: {        // Composite unique key
      userId: req.user.id,
      productId
    }
  },
  update: {
    quantity: { increment: quantity }   // Record exists — just increment
  },
  create: {
    userId: req.user.id,
    productId,
    quantity                            // Record doesn't exist — create it
  }
})
Enter fullscreen mode Exit fullscreen mode

delete

await prisma.task.delete({ where: { id: taskId } })
Enter fullscreen mode Exit fullscreen mode

deleteMany

// Clear the entire cart after placing an order
await prisma.cartItem.deleteMany({ where: { userId: req.user.id } })
Enter fullscreen mode Exit fullscreen mode

Part 8 — Transactions ($transaction)

A transaction groups multiple operations so they either all succeed or all fail together. This is critical any time you're doing multiple writes that depend on each other.

const result = await prisma.$transaction(async (tx) => {
  // Use 'tx' instead of 'prisma' inside here
  const order = await tx.order.create({ data: { ... } })

  await tx.cartItem.deleteMany({ where: { userId } })

  for (const item of cartItems) {
    await tx.product.update({
      where: { id: item.productId },
      data: { stock: { decrement: item.quantity } }
    })
  }

  return order
})
Enter fullscreen mode Exit fullscreen mode

If any operation inside throws an error, Prisma rolls back everything — none of it hits the database. This is how you protect against partial failures (order created but cart not cleared, or stock decremented but order creation failed).

To trigger a rollback manually, just throw inside the transaction:

const result = await prisma.$transaction(async (tx) => {
  if (cartItems.length === 0) throw new Error('Cart is empty')
  // ... rest of the logic
})
Enter fullscreen mode Exit fullscreen mode

Part 9 — orderBy, take, skip

These work together for sorting and pagination.

// Sort by one field
orderBy: { createdAt: 'desc' }

// Sort by multiple fields
orderBy: [
  { priority: 'asc' },
  { createdAt: 'desc' }
]

// Limit results
take: 20

// Skip N records (for pagination)
// Page 1: skip 0, take 20
// Page 2: skip 20, take 20
// Page 3: skip 40, take 20
skip: (page - 1) * limit

// Sort related records inside include
include: {
  boards: {
    orderBy: { order: 'asc' }
  }
}
Enter fullscreen mode Exit fullscreen mode

Part 10 — Putting It All Together

Here's a realistic "get project with everything" query that combines most of what's above:

const project = await prisma.project.findUnique({
  where: { id: projectId },
  include: {
    // All members with just the fields we need from User
    members: {
      include: {
        user: {
          select: { id: true, name: true, email: true }
        }
      }
    },
    // All boards sorted by order
    boards: {
      orderBy: { order: 'asc' },
      include: {
        tasks: {
          include: {
            // Assignee — only need id and name
            assignedTo: {
              select: { id: true, name: true }
            },
            // Comment count without loading the actual comments
            _count: {
              select: { comments: true }
            }
          }
        }
      }
    }
  }
})

// Check the user is actually a member before returning
const isMember = project.members.some(m => m.userId === req.user.id)
if (!isMember) return res.status(403).json({ message: 'Not a member' })
Enter fullscreen mode Exit fullscreen mode

Quick Reference

What you want How to do it
Filter records where: { field: value }
Filter with comparison where: { price: { gt: 50 } }
Text search where: { name: { contains: q, mode: 'insensitive' } }
Either/or filter where: { OR: [...] }
Filter by related record where: { relation: { some: { field: value } } }
Include related data include: { relation: true }
Include and filter related include: { relation: { where: { ... } } }
Pick specific fields select: { id: true, name: true }
Pick fields on included relation include: { relation: { select: { ... } } }
Count related records include: { _count: { select: { relation: true } } }
Sort results orderBy: { field: 'asc' }
Limit results take: N
Paginate take: N, skip: N
Create with related records create: { data: { relation: { create: [...] } } }
Add or increment (upsert) upsert: { where, update, create }
Increment a number atomically update: { data: { stock: { decrement: 5 } } }
Update many at once updateMany: { where, data }
Delete many at once deleteMany: { where }
Group multiple writes safely $transaction(async (tx) => { ... })

Top comments (0)