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
Rails creates migration files and model classes for you.
Run the migrations:
bin/rails db:migrate
Now open the generated model files and add the associations.
app/models/user.rb
class User < ApplicationRecord
has_many :projects, dependent: :destroy
end
app/models/project.rb
class Project < ApplicationRecord
belongs_to :user
has_many :documents, dependent: :destroy
end
app/models/document.rb
class Document < ApplicationRecord
belongs_to :project
end
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
A few important points:
-
t.references :projectaddsproject_id -
foreign_key: truetells the database to enforce the relationship -
timestampsaddscreated_atandupdated_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
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"
)
Load it:
bin/rails db:seed
Now open the Rails console:
bin/rails console
Query with Active Record
Basic queries look like plain English.
User.count
Project.count
Document.count
Find a record by id:
user = User.find(1)
Query by column values:
Document.where(status: "draft")
Document.find_by(title: "RAG Checklist")
Sort and limit:
Document.order(created_at: :desc).limit(5)
Select only published documents:
Document.where(status: "published")
Chain queries together:
Document.where(status: "published").order(:title)
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
project = Project.first
project.documents
Create associated records through the relationship:
project.documents.create!(
title: "Embedding Notes",
body: "Cosine similarity compares vector direction.",
status: "draft"
)
Follow the relationship in the other direction:
document = Document.first
document.project
document.project.user
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
Now your queries read better:
Document.published.recent
project.documents.drafts
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")
Delete a record:
document.destroy
Because we used dependent: :destroy, deleting a project also deletes its documents:
project.destroy
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)
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:
- Add a
summary:textcolumn todocuments - Add a
has_many :documents, through: :projectsassociation onUser - Create a scope called
ready_for_embedding - 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)