Coming from Rails
Models, validations, callbacks, associations, scopes, and dirty tracking all follow Active Record, down to the default error messages. Here’s what’s different.
Querying from the Model
// Rails: User.where(active: true)
const users = await User.where({ isActive: true }).toArray();A Model queries by itself. Its binding, static override binding = "DB" on your app’s base class, plays the part of connects_to.
A Worker serves many requests at once from one isolate, so nothing about a request is stored on the class. For a D1 Session or per-request logging, run the request in withDatabase. A record keeps the database it came from.
Naming things
Most names are Rails’ own, in camelCase:
| Rails | d1-record |
|---|---|
created_at (attribute) | createdAt, mapped to created_at |
foreign_key: :user_id | foreignKey: "userId" |
save!, create!, find_by! | saveOrThrow, createOrThrow, findByOrThrow |
new_record?, persisted? | isNewRecord(), isPersisted() |
empty?, any? | isEmpty(), hasAny() |
changed?, saved_change_to_email? | isChanged(), hasSavedChangeTo("email") |
rails generate model Post title:string | npx d1-record g model Post title:string |
db/schema.rb, rails db:schema:dump | src/db/schema.ts, npx d1-record schema |
class Post < ApplicationRecord | class Post extends ApplicationRecord("posts") |
scope :active, -> { … } | static active = this.scope((q) => …) |
has_many :posts, dependent: :destroy | posts = hasMany(Post, { dependent: "destroy" }) |
post.author = user | post.author.set(user) |
build_profile | user.profile.build() |
reload_author | post.reloadAssociation("author") |
throw :abort | throw abort("reason") |
to_json | toJSON() |
Attributes, conditions, and association keys all name attributes, in camelCase. Column names only appear inside sql fragments. Foreign keys follow Rails’ conventions, so User’s has_many uses userId.
Declaring Models
Migrations define the columns, and a Model declares only behavior. Where Rails reads the columns when the app runs, d1-record reads them into src/db/schema.ts when you run d1-record schema or a generator, so TypeScript knows every attribute’s type.
A Model names its table, since TypeScript can’t derive a class’s types from its name. Scopes are statics, and validations and callbacks go in a static {} block:
import { ApplicationRecord } from "./application-record";
// Rails:
// class Product < ApplicationRecord
// scope :in_stock, -> { where(available: true) }
// validates :name, presence: true
// end
export class Product extends ApplicationRecord("products", { available: "boolean" }) {
static inStock = this.scope((q) => q.where({ available: true }));
static {
this.validates("name", { presence: true });
}
}Writing atomically
There’s no transaction do … end, since D1 can’t keep a transaction open across awaits. Instead, batch([...]) writes a prepared set of statements and records atomically, with records’ toSave() and toDestroy(), and bulk writes’ toUpdateAll(), toDeleteAll(), and so on. Generating keys in code makes whole graphs of new records atomic too. See D1’s limits.
Primary keys
Keys are UUIDs, not integers
g model gives each table a TEXT id generated in code, which is what makes those batches atomic. --id integer gives you integer keys that D1 assigns, without that guarantee.
Generating code
The generators follow rails generate model and rails generate migration. A column can also have a literal default, count:integer!=0, which Rails’ generators can’t express. See Generators.
Smaller differences
insertandinsertAllraise on duplicates. Skipping them is opt-in, with{ onDuplicate: "skip" }.insertfills in declared defaults, since that’s where keys are generated.mergereplaces the order rather than appending to it.last()can’t reverse ansqlfragment order, and throwsIrreversibleOrder, rather than trying to parse SQL.find(1, 1)always returns an array, so its return type follows its arguments.dependent:strategies run after the owner’sbeforeDestroycallbacks, wherever they’re declared, so anabort()always protects the records.- Records in a batch run all their before-callbacks before any write, and their after-callbacks after all of them.
- Scopes preloaded with
limitthrow, where Rails silently gives wrong results.
Not built yet
Default scopes, has_many :through, polymorphic associations, single-table inheritance, counter caches, destroy_async, and i18n of error messages.