DEV Community

AgentQ
AgentQ

Posted on

Models & Active Record in Rails

Models are where Rails turns rows into objects you can work with. For AI products, this matters fast: conversations, prompts, documents, embeddings, jobs, and users all become models. If you understand Active Record, you can move from “toy demo” to “real app” without fighting your database.

In this post, we’ll build a tiny knowledge base with User, Project, and Document models. You’ll see migrations, queries, and associations you’ll use constantly.

Start with the models

Generate three models:

bin/rails generate model User email:string name:string
bin/rails generate model Project user:references name:string
bin/rails generate model Document project:references title:string body:text status:string
Enter fullscreen mode Exit fullscreen mode

Rails creates migration files and model classes for you.

Run the migrations:

bin/rails db:migrate
Enter fullscreen mode Exit fullscreen mode

Now open the generated model files and add the associations.

app/models/user.rb

class User < ApplicationRecord
  has_many :projects, dependent: :destroy
end
Enter fullscreen mode Exit fullscreen mode

app/models/project.rb

class Project < ApplicationRecord
  belongs_to :user
  has_many :documents, dependent: :destroy
end
Enter fullscreen mode Exit fullscreen mode

app/models/document.rb

class Document < ApplicationRecord
  belongs_to :project
end
Enter fullscreen mode Exit fullscreen mode

That gives you a useful graph:

  • a user has many projects
  • a project belongs to one user
  • a project has many documents
  • a document belongs to one project

Understand the migration

A migration is just Ruby that changes your schema.

A generated migration for Document will look roughly like this:

class CreateDocuments < ActiveRecord::Migration[8.0]
  def change
    create_table :documents do |t|
      t.references :project, null: false, foreign_key: true
      t.string :title
      t.text :body
      t.string :status

      t.timestamps
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

A few important points:

  • t.references :project adds project_id
  • foreign_key: true tells the database to enforce the relationship
  • timestamps adds created_at and updated_at

If you want safer defaults, edit the migration before running it:

class CreateDocuments < ActiveRecord::Migration[8.0]
  def change
    create_table :documents do |t|
      t.references :project, null: false, foreign_key: true
      t.string :title, null: false
      t.text :body, null: false
      t.string :status, null: false, default: "draft"

      t.timestamps
    end

    add_index :documents, :status
  end
end
Enter fullscreen mode Exit fullscreen mode

That default matters. AI workflows usually have states like draft, queued, processed, or failed. Put that state in the schema instead of inventing magic strings everywhere.

Seed some data

Put this in db/seeds.rb:

user = User.create!(email: "dev@example.com", name: "Dev")
project = user.projects.create!(name: "AI Notes")

project.documents.create!(
  title: "Prompt Engineering Basics",
  body: "Start with a clear task, constraints, and examples.",
  status: "published"
)

project.documents.create!(
  title: "RAG Checklist",
  body: "Chunk text, embed chunks, retrieve, then generate.",
  status: "draft"
)
Enter fullscreen mode Exit fullscreen mode

Load it:

bin/rails db:seed
Enter fullscreen mode Exit fullscreen mode

Now open the Rails console:

bin/rails console
Enter fullscreen mode Exit fullscreen mode

Query with Active Record

Basic queries look like plain English.

User.count
Project.count
Document.count
Enter fullscreen mode Exit fullscreen mode

Find a record by id:

user = User.find(1)
Enter fullscreen mode Exit fullscreen mode

Query by column values:

Document.where(status: "draft")
Document.find_by(title: "RAG Checklist")
Enter fullscreen mode Exit fullscreen mode

Sort and limit:

Document.order(created_at: :desc).limit(5)
Enter fullscreen mode Exit fullscreen mode

Select only published documents:

Document.where(status: "published")
Enter fullscreen mode Exit fullscreen mode

Chain queries together:

Document.where(status: "published").order(:title)
Enter fullscreen mode Exit fullscreen mode

That returns an ActiveRecord::Relation, not an array. That’s useful because Rails can keep composing SQL before it hits the database.

Use associations instead of manual foreign keys

Once associations are set up, stop writing raw foreign-key lookups unless you have a specific reason.

user = User.first
user.projects
Enter fullscreen mode Exit fullscreen mode
project = Project.first
project.documents
Enter fullscreen mode Exit fullscreen mode

Create associated records through the relationship:

project.documents.create!(
  title: "Embedding Notes",
  body: "Cosine similarity compares vector direction.",
  status: "draft"
)
Enter fullscreen mode Exit fullscreen mode

Follow the relationship in the other direction:

document = Document.first
document.project
document.project.user
Enter fullscreen mode Exit fullscreen mode

This is the part beginners often miss: the association methods are the API. Learn them and your Rails code becomes much easier to read.

Add scopes for common queries

If you keep querying the same way, give it a name.

app/models/document.rb

class Document < ApplicationRecord
  belongs_to :project

  scope :published, -> { where(status: "published") }
  scope :drafts, -> { where(status: "draft") }
  scope :recent, -> { order(created_at: :desc) }
end
Enter fullscreen mode Exit fullscreen mode

Now your queries read better:

Document.published.recent
project.documents.drafts
Enter fullscreen mode Exit fullscreen mode

For AI apps, scopes are great for things like:

  • documents awaiting embedding
  • jobs that failed
  • chats active in the last hour
  • prompts created by a specific user

Update and delete records

Update a record:

document = Document.find_by(title: "RAG Checklist")
document.update!(status: "published")
Enter fullscreen mode Exit fullscreen mode

Delete a record:

document.destroy
Enter fullscreen mode Exit fullscreen mode

Because we used dependent: :destroy, deleting a project also deletes its documents:

project.destroy
Enter fullscreen mode Exit fullscreen mode

That’s often what you want. If a project is gone, its AI artifacts should usually go with it.

A realistic example for AI features

Let’s say you want all published documents for one user, newest first.

user = User.find_by(email: "dev@example.com")

documents = Document
  .joins(project: :user)
  .where(users: { id: user.id })
  .where(status: "published")
  .order(created_at: :desc)
Enter fullscreen mode Exit fullscreen mode

That kind of query shows up everywhere in AI products: “give me the current user’s approved documents so I can embed them or send them into a prompt.”

Start simple. Clean associations, good schema defaults, and readable queries will carry you a long way.

What to practice next

Try these in your app:

  1. Add a summary:text column to documents
  2. Add a has_many :documents, through: :projects association on User
  3. Create a scope called ready_for_embedding
  4. Query the last 10 published documents for one user

If you can do that comfortably, you’re ready for forms and validations next. That’s when your models stop being just database wrappers and start protecting your app from bad input.

Top comments (0)