An Engine is a connection to a database. Under the hood,
engines call DBI::dbConnect() to create and manage
connections. This gives you flexibility to use any database supported by
the DBI package. Connecting to a database with
Engine is effectively the same as using
DBI::dbConnect() directly.
So why wrap it in an R6 object?
- To manage opening and closing of connections
- To optionally support connection pooling
- To abstract some repetitive or database-specific boilerplate
Creating an Engine
When creating an Engine, there are two additional arguments worth knowing:
persist = TRUE: Keeps the connection open between operations likemodel()orexecute(). This is required when using an in-memory SQLite database, otherwise the data will be lost between calls.use_pool = TRUE: Enables connection pooling (via thepoolpackage), which improves performance in environments like Shiny by reusing open connections across sessions.
Common Methods
The full API is documented in the Engine reference page, but here are the most commonly used methods:
model()Creates aTableModelobject from the Engine. See the TableModel vignettereflect()Builds aTableModelby introspecting an existing table, so you can do basic CRUD without declaring columns. See Reflecting models from existing tables below.reflect_schema()Reflects all tables in a schema at once and auto-wires relationships from reflected foreign keys. See Reflecting an entire schema below.get_connection()Returns the underlying DBI connection. You can use this directly for raw SQL or withdplyr::tbl()for custom queries.execute(sql)A lightweight wrapper aroundDBI::dbExecute()using the Engine’s connection.get_query(sql)Executes a query and returns the results as a data.frame usingDBI::dbGetQuery().
# Run a raw SQL statement
engine$execute("CREATE TABLE things (id INTEGER PRIMARY KEY, name TEXT)")
# Retrieve a result
df <- engine$get_query("SELECT * FROM things")Reflecting models from existing tables
When a table already exists in the database, reflect()
inspects its columns and returns a ready-to-use TableModel.
This avoids re-declaring every column when you just need basic CRUD
against an existing table.
# Reflect every column of the existing "users" table
Users <- engine$reflect("users")
names(Users$fields)
# Narrow the model with include / exclude
Users <- engine$reflect("users", include = c("id", "name", "age"))
Users <- engine$reflect("users", exclude = c("hash", "configuration"))
Users$record(id = 3L, name = "Ada", age = 36L)$create()
Users$read(.mode = "data.frame")The depth of reflection depends on the dialect:
- Default (all dialects): column names and best-effort types only.
-
PostgreSQL: canonical types, primary key flags,
nullability, column defaults (server-side defaults such as
nextval()are stored asdbplyr::sql()and applied by the database at insert time), and foreign keys returned asForeignKeyobjects — schema-qualified when the target lives in another schema.
For dialects that do not reflect primary keys, update()
and delete() (which key off declared PK fields) require you
to supply the key column via .... Arguments in
... take precedence over reflected columns, mirroring
model():
Users <- engine$reflect(
"users",
id = Column("INTEGER", primary_key = TRUE)
# ... also accepts Method()s or column-type overrides
)Reflecting an entire schema
reflect_schema() reflects every table in a schema in one
call and automatically wires up many_to_one /
one_to_many relationships implied by the reflected foreign
keys. This is most useful with the PostgreSQL dialect, whose reflection
captures foreign keys.
# Reflect all tables in the engine's default schema
models <- engine$reflect_schema()
# Or target specific tables
models <- engine$reflect_schema(tables = c("users", "posts", "comments"))
# Access individual models
posts <- models$posts
users <- models$users
# Relationships are wired automatically from FK metadata
post <- posts$read(id == 1, .mode = "get")
author <- post$relationship("users") # traverses posts.user_id -> users.idKey arguments:
| Argument | Default | Description |
|---|---|---|
tables |
NULL |
Tables to reflect; NULL reflects all in the schema |
exclude |
NULL |
Table names to skip |
.schema |
engine schema | Schema to inspect |
wire_relationships |
TRUE |
Auto-wire FK-based relationships |
Foreign keys pointing at tables outside the reflected set are
silently skipped with a warning. You can always call
model$define_relationship() manually afterwards to wire
additional relationships.
Using with.Engine
with.Engine is an S3 method for managing transaction
state. It allows you to run code within a database transaction, and if
an error occurs during execution, it will automatically roll back the
transaction.
This provides a safeguard against partial writes or inconsistent
states. If you’re familiar with Python’s sqlalchemy, the
usage will feel familiar.
with(engine, {
user = Users$record(name = "John Doe")
user$create()
})You can also choose to manually commit or roll back transactions. This gives you full control over error handling:
with(engine, {
user = Users$record(name = "Jane Doe")
tryCatch({
user$create()
}, error = function(e) {
print(paste("An error occurred:", e$message))
rollback()
})
commit()
}, auto_commit = FALSE)This approach helps ensure that your data remains in sync with the database state. If any part of the transaction fails, with.Engine() will automatically clean up and alert you with a meaningful error.
Dialects
Engines also store the dialect of the database they connect to. This is used to translate SQL statements into the correct syntax for the specific database backend.
Different databases handle data types, operators, and functions in
slightly different ways. oRm’s dialect system isn’t
exhaustive, but it covers key differences that affect features like
flushing or value-returning inserts.
For example: - PostgreSQL supports RETURNING * to fetch
inserted rows immediately. - SQLite does not support
RETURNING, so oRm falls back to using
last_insert_rowid().
These dialect-specific behaviors are handled automatically, so your code can stay consistent regardless of the database in use.
Read-Only Engines
Pass .read_only = TRUE when creating an Engine to
prevent all write operations. This is useful when giving analysts a
connection to a production database, running exploratory queries against
live data, or any situation where accidental writes would be
harmful.
ro_engine <- Engine$new(
drv = RPostgres::Postgres(),
dbname = "prod_db",
host = "db.example.com",
.read_only = TRUE
)What is blocked
Any statement that is not a read (SELECT,
WITH, EXPLAIN, SHOW,
PRAGMA, VALUES) is rejected with an error
before it reaches the database:
# Fine
ro_engine$get_query("SELECT count(*) FROM users")
# Blocked at the application level
ro_engine$execute("DELETE FROM users WHERE id = 1")
#> Error: Engine is read-only; refusing to execute non-SELECT statement.
# TableModel and Record write methods are also blocked
User$record(id = 99, name = "Ghost")$create()
#> Error: Engine is read-only; refusing write operation.
User$create_table()
#> Error: Engine is read-only; cannot create tables.Dialect-level enforcement
In addition to application-level guards, oRm applies a
connection-level read-only flag appropriate to each backend:
| Dialect | Mechanism |
|---|---|
| SQLite |
RSQLite::SQLITE_RO open flag — the connection itself
cannot write |
| PostgreSQL | libpq options="-c default_transaction_read_only=on" —
every transaction is read-only at the driver level |
| MySQL |
SET SESSION TRANSACTION READ ONLY executed after each
new connection is opened |
For pooled MySQL connections, driver-level enforcement is not
available; oRm will emit a warning and fall back to
application-level guards only.