The sql!{} macro is how pell programs talk to the database. Inside
the braces you write Oracle SQL exactly as you would in any other
client; pell handles bind extraction, terminator splicing, and result
shape. This chapter covers every common pattern.
The basic shape
sql! { select id, name from employees where active = 1 }
That on its own is a cursor expression — useful as the iterable of a
for loop, but you can’t read its rows yet. You attach a terminator
to say what you want done with the rows.
| Terminator | Behavior |
|---|---|
.one() |
Exactly one row. NO_DATA_FOUND / TOO_MANY_ROWS error. |
.one_or_none() |
Zero or one row. Returns Option<T>. |
.first() |
First row only. NULL if no rows. |
.collect() |
All rows into a list<T>. |
.rowcount() |
For DML — returns number of rows affected. |
(none, in for) |
Streams via cursor FOR loop. |
Reading a single row
pub fn lookup_name(id: number) -> text {
let name: text = sql! {
select name from employees where id = :id
}.one();
return name;
}
lowers to:
FUNCTION lookup_name(p_id IN NUMBER) RETURN VARCHAR2 IS
l_name VARCHAR2(4000);
BEGIN
BEGIN
SELECT name
INTO l_name
FROM employees WHERE id = p_id;
EXCEPTION
WHEN NO_DATA_FOUND THEN RAISE;
WHEN TOO_MANY_ROWS THEN RAISE;
END;
RETURN l_name;
END lookup_name;
The :id inside the SQL is a bind reference to the pell variable
id. Pell extracts binds at compile time and rewrites them to the
PL/SQL local name (p_id because it’s a parameter, l_id for locals).
Multi-column rows → records
If your SELECT returns more than one column, the receiving local must be a record type and the SELECT list must match in count and order:
pub record EmpRow { id: number, name: text }
pub fn lookup(id: number) -> EmpRow {
let row: EmpRow = sql! {
select id, name from employees where id = :id
}.one();
return row;
}
lowers to:
BEGIN
SELECT id, name
INTO l_row.id, l_row.name
FROM employees WHERE id = p_id;
EXCEPTION
WHEN NO_DATA_FOUND THEN RAISE;
WHEN TOO_MANY_ROWS THEN RAISE;
END;
Custom NotFound exceptions
If you want a domain-specific error rather than a bare NO_DATA_FOUND, declare it and pell will wire it up:
pub error EmployeeNotFound { id: number }
pub fn lookup(id: number) -> Result<EmpRow, EmployeeNotFound> {
let row: EmpRow = sql! {
select id, name from employees where id = :id
}.one();
return Ok(row);
}
Pell sees the Result<EmpRow, EmployeeNotFound> return type and
rewires the NO_DATA_FOUND handler to raise the typed error. See
Errors.
Reading multiple rows
pub fn list_active() -> list<EmpRow> {
let xs: list<EmpRow> = sql! {
select id, name from employees where active = 1
}.collect();
return xs;
}
lowers to:
FUNCTION list_active RETURN t_emp_row_list IS
l_xs t_emp_row_list;
BEGIN
SELECT id, name
BULK COLLECT INTO l_xs
FROM employees WHERE active = 1;
RETURN l_xs;
END list_active;
.collect() becomes BULK COLLECT — a single round-trip that loads all
rows. For very large result sets, prefer streaming via a for loop
(below) so you don’t materialize everything in memory.
Streaming with for
When you want to process rows one at a time without loading them all:
pub record EmpRow { id: number, name: text }
pub fn print_active() {
for row in sql! {
select id, name from employees where active = 1
} {
log::info("emp {row.id}: {row.name}");
}
}
lowers to:
PROCEDURE print_active IS
BEGIN
FOR row IN (
SELECT id, name FROM employees WHERE active = 1
) LOOP
log.info('emp ' || row.id || ': ' || row.name);
END LOOP;
END print_active;
A PL/SQL cursor FOR loop. Oracle automatically opens, fetches, and closes the cursor; rows are pulled one (or a batch) at a time.
DML
INSERT / UPDATE / DELETE / MERGE work the same way. Use .rowcount()
to get the affected-row count:
pub fn deactivate(id: number) -> number {
let n: number = sql! {
update employees set active = 0 where id = :id
}.rowcount();
return n;
}
lowers to:
FUNCTION deactivate(p_id IN NUMBER) RETURN NUMBER IS
l_n NUMBER;
BEGIN
UPDATE employees SET active = 0 WHERE id = p_id;
l_n := SQL%ROWCOUNT;
RETURN l_n;
END deactivate;
If you don’t need the count, drop the .rowcount() and assign nothing:
pub fn deactivate(id: number) {
sql! { update employees set active = 0 where id = :id };
}
INSERT … RETURNING
When you want the new row’s ID (or other generated columns) back from an INSERT:
pub fn add_employee(name: text) -> number {
let id: number = sql! {
insert into employees (id, name)
values (employees_seq.nextval, :name)
returning id
}.returning::<number>().one();
return id;
}
lowers to:
FUNCTION add_employee(p_name IN VARCHAR2) RETURN NUMBER IS
l_id NUMBER;
BEGIN
INSERT INTO employees (id, name)
VALUES (employees_seq.nextval, p_name)
RETURNING id INTO l_id;
RETURN l_id;
END add_employee;
The .returning::<T>().one() chain tells pell what type to expect back
and asserts exactly one row.
Locking modifiers
After a SELECT, chain locking methods:
pub fn lock_employee(id: number) -> EmpRow {
let row: EmpRow = sql! {
select id, name from employees where id = :id
}.for_update().nowait().one();
return row;
}
lowers to:
SELECT id, name
INTO l_row.id, l_row.name
FROM employees WHERE id = p_id
FOR UPDATE NOWAIT;
Available locking methods: .for_update(), .nowait(),
.skip_locked(), .wait(n), .for_update_of(col1, col2).
Bind safety
Pell rewrites :name references to the local PL/SQL name (p_name,
l_name, etc.), so the value is always passed as a bind parameter —
not concatenated into the SQL string. This means:
- No SQL injection risk for normal queries.
- Oracle’s shared-pool cache hits on the rewritten SQL, regardless of the parameter value.
- Date/timestamp parameters use the right binding, no
TO_DATEhoops.
For dynamic SQL where you need to splice an identifier (a table name,
a column name) you have to opt into unsafe fn + exec_dyn. See
Dynamic SQL.
Multi-line SQL and whitespace
Pell preserves whitespace inside sql!{} blocks. Write your SQL with
the indentation that reads best:
sql! {
select e.id,
e.name,
d.name as department
from employees e
join departments d on d.id = e.dept_id
where e.active = 1
and e.salary > :min_salary
order by e.salary desc
}
Comments inside sql!{} work normally — both -- line comments and
/* ... */ blocks pass through unchanged.
Where to go next
- Iteration —
forallfor bulk DML - Errors and @retry — Custom error types
- automatic retry
- Pipelined and parallel — table-function streaming