Rado GitHub

Getting started

Five minutes from npm install to typed query results. Here's the whole journey: install, connect, define a schema, create tables, query.

Install

npm install rado

You'll also need a database driver. Rado doesn't ship one. It wraps the client you already use. For this guide we'll use SQLite via better-sqlite3:

npm install better-sqlite3

Using Deno? Rado is on JSR as @rado/rado.

Connect

Every driver lives under rado/driver/* and exports a connect function that takes an instance of the underlying client:

import Database from 'better-sqlite3'
import {connect} from 'rado/driver/better-sqlite3'

const db = connect(new Database('app.db'))

That is the only driver-specific line in this guide. Everything that follows is identical for PostgreSQL and MySQL. See Drivers for the full list of supported clients.

One detail to know: better-sqlite3 is a synchronous driver, so db is a synchronous database. You can await queries (they're thenable), or skip the await entirely and get results right away. Async drivers like pg give you an async database where await is required. The query building API is the same either way.

Define a schema

A table is a name plus an object of columns. Column types come from the dialect module. rado/sqlite here:

import {sqliteTable} from 'rado/sqlite'
import {integer, text} from 'rado/sqlite'

const User = sqliteTable('user', {
  id: integer().primaryKey({autoIncrement: true}),
  name: text().notNull(),
  email: text().unique()
})

const Post = sqliteTable('post', {
  id: integer().primaryKey({autoIncrement: true}),
  authorId: integer()
    .notNull()
    .references(() => User.id),
  title: text().notNull(),
  body: text()
})

A few things to notice:

  • Column names are inferred from the property names. name: text() creates a column named name. Pass a string (text('full_name')) only when the database column name differs from the property.
  • Modifiers chain: .notNull(), .unique(), .primaryKey(), .default(), .references(). They also narrow the TypeScript types. A .notNull() column won't be | null in your results.
  • sqliteTable is a dialect-typed version of the generic table export from rado. If you want schema that runs on any database, use table with column types from rado/universal. See Universal queries.

More on all of this in Tables.

Create the tables

db.create(User, Post)

db.create emits create table (and any index) statements. For evolving an existing database to match your schema, there's db.migrate. See Migrations.

Insert some rows

db.insert(User).values([
  {name: 'Ada Lovelace', email: 'ada@example.com'},
  {name: 'Grace Hopper', email: 'grace@example.com'}
])

const ada = db
  .select()
  .from(User)
  .where(eq(User.email, 'ada@example.com'))
  .get() // .get() returns the first row (or undefined)

db.insert(Post).values({
  authorId: ada.id,
  title: 'Notes on the Analytical Engine'
})

Inserted values are type-checked against the table definition: required columns must be present, columns with defaults are optional, unknown keys are rejected. Forget name and TypeScript complains before SQLite ever gets the chance.

Query

import {eq} from 'rado'

// Everything
const users = db.select().from(User)
// [{id: 1, name: 'Ada Lovelace', email: 'ada@example.com'}, ...]

// A subset of columns, with results typed accordingly
const names = db.select({id: User.id, name: User.name}).from(User)

// A single expression, with results returned as an array of values
const justNames = db.select(User.name).from(User)
// ['Ada Lovelace', 'Grace Hopper']

// With a join
const postsWithAuthors = db
  .select({title: Post.title, author: User.name})
  .from(Post)
  .innerJoin(User, eq(Post.authorId, User.id))

Queries are immutable: every method returns a new query, so you can build a base query once and branch it freely:

const posts = db.select().from(Post)
const byAda = posts.where(eq(Post.authorId, ada.id))
const recent = posts.orderBy(desc(Post.id)).limit(10)
// `posts` itself is untouched and still queries everything

Where to next?