Verdict
Cautiously yes — but only if §6 (errors) lands the way the doc claims and the
finally foot-gun gets a real lint, not just a doc note. The language is at
its best when it’s helping me grep, narrow my catches, and stop writing
DECLARE … BEGIN … EXCEPTION WHEN OTHERS THEN log; RAISE; END; for the
ten-thousandth time. It’s at its worst when it borrows Rust ergonomics that
sound right in a slide deck but multiply the surface area (Option<T> and
T?, four row terminators, with (…) after sql!{…}, pipelines that
materialize and silently dodge SQL). The doc is mostly honest already; my
edits push it further toward “we are choosing a tradeoff” and away from
“this is just better.”
Strong points
- §4.5.1 “no explicit cursors” is the single most valuable feature in the whole document. The lowering sketch is the kind of thing that earns trust.
- §6.5 separating
.first()/.one()/.expect()by intent is the right insight.NO_DATA_FOUNDreally is the worst PL/SQL wart and this is a clean answer. - §6.3 framing
finallyas “cleanup, not handling” is the right framing — it just needs guard-rails (see Concerns). - §9 annotations as a closed, validated set is genuinely better than the PL/SQL
grab-bag, especially the rule that
@deterministicon a fn with observable side effects is a compile error. That’s a real win. - Source maps (§4.1, §8) are non-negotiable for a transpiled language and the doc treats them as such.
- Non-goals (§2) are crisp. “Not a SQL replacement” alone justifies half the scope.
- §11 is mostly tradeoffs with stated biases — exactly the right tone.
Concerns
- §4.5
with (status = "ACTIVE", dept_id = my_dept)— pure ceremony.:statusalready resolves from lexical scope per the same paragraph, sowithis just renaming locals into themselves. Edited out. - §6.3
finallyfoot-gun is buried. The doc literally has the examplelog::error("do_stuff failed"); // only logs on error? no — always logs.That’s the migration foot-gun in plain sight. Without a lint, every team portingWHEN OTHERS THEN log; RAISE;tofinallywill log “failed” on successful calls and not notice for months. Added a real lint proposal. - §6.5 four row terminators is one too many.
.one_or_none()is the odd one out — it’s.one()minus the absence-is-error semantics, which is almost always exactly the semantics you wanted. Reordered the table to put.one()first as the boring default and added prose telling readers when not to reach for.one_or_none(). Would not be sorry to see it cut entirely in v1. T?vsOption<T>duplication. Doc had them as exact synonyms with no guidance. PickedT?as the surface form and madeOption<T>an internal alias used by prelude signatures only.pell fmtrewrites the other way.- §6.5.1 “uncatchable invariant panics” is overstated. The emitted
RAISE_APPLICATION_ERROR(-20001)is a perfectly normal Oracle exception and any hand-written PL/SQL upstream can catch it withWHEN OTHERS. The doc’s “uncatchable” claim is true withinpellbut not at the runtime boundary. Added an honesty caveat. - §3 table weakest rows. Three I’d argue against:
Comments— cut. Pure churn; doesn’t earn a table row. Editor support handles both anyway.--vs//- “Compiler hints” row oversold the symmetry (it makes annotations look 1:1 with pragmas; §9.2 shows the real constraints). Rewrote to mention closed set + validated combinations.
- “Records” row implies
record User { … }is a strict win overTYPE … IS RECORD, but doesn’t mention the lowering cost (every record becomes a%rowtype-shaped PL/SQL record type and a marshaller). Didn’t edit — keep an eye on this in M1.
- §4.6 pipelines are oversold. Without auto-fusion (punted to v2), a
pipeline over a
sql!{}source materializes the result set and runsfilter/mapin PL/SQL. That’s a real perf trap for large queries. Added the caveat in-line; would not be sad to cut §4.6 entirely until v2. - §13 (prior art) and §14 (naming) add no engineering value. §14 trimmed to one sentence; §13 left because it’s at least defensible as “reading list.”
fnreturningUnit= procedure (§3, §4.1). Distinguishing procedures from functions in PL/SQL has stack-trace implications (procedures don’t appear inDBMS_UTILITY.FORMAT_CALL_STACKthe same way). Worth checking whether the unification costs us debugging clarity. Not edited; flagged for Shaun.set<T> = map<T, unit>sugar (§5.1). Cute, but iteration order will be unpredictable in ways users won’t expect from a “set.” Not edited.
A realistic 200-line module sketch
This is what convinced me the language is worth the trouble — and where I found half the concerns above. Module is a billing-ish thing because that’s where I spend my outages.
module billing.charges;
import std::log;
import std::time::{now, today};
import billing::accounts;
// --- types -------------------------------------------------------------
record Charge {
id: number,
account_id: number,
amount: number(12, 2),
currency: text, // ISO 4217
status: ChargeStatus,
idem_key: text,
created_at: timestamp,
posted_at: timestamp?, // null until posted
failure_code: text?, // null on success
}
enum ChargeStatus { Pending, Posted, Failed, Refunded }
// --- errors ------------------------------------------------------------
error NotFound { entity: text, id: number }
error InsufficientFunds { account_id: number, shortfall: number(12, 2) }
error AccountFrozen { account_id: number, reason: text }
error DuplicateCharge { idem_key: text }
error LedgerOutOfSync { account_id: number, expected: number, found: number }
// --- read paths --------------------------------------------------------
pub fn find_charge(id: number) -> Result<Charge, NotFound> {
return sql! {
select id, account_id, amount, currency, status,
idem_key, created_at, posted_at, failure_code
from charges where id = :id
}.one().map_err(|_| NotFound { entity: "charge", id });
}
pub fn list_pending_for(account_id: number) -> list<Charge> {
return sql! {
select id, account_id, amount, currency, status,
idem_key, created_at, posted_at, failure_code
from charges
where account_id = :account_id and status = 'PENDING'
order by created_at
}.collect();
}
// --- write path --------------------------------------------------------
pub fn post_charge(
account_id: number,
amount: number(12, 2),
currency: text,
idem_key: text,
) -> Result<Charge, InsufficientFunds | AccountFrozen | DuplicateCharge | LedgerOutOfSync> {
let span = log::span("post_charge", account_id, amount, currency, idem_key);
// Idempotency: same key on the same account is a no-op return, not an error.
let existing = sql! {
select id, account_id, amount, currency, status,
idem_key, created_at, posted_at, failure_code
from charges
where account_id = :account_id and idem_key = :idem_key
}.first();
if let Some(c) = existing {
return Ok(c);
}
let account = accounts::find_locked(account_id)?; // RowLock; bubbles NotFound? no, accounts::find_locked panics on missing
if account.frozen {
return Err(AccountFrozen {
account_id,
reason: account.freeze_reason.expect("frozen account has a reason"),
});
}
if account.balance < amount {
return Err(InsufficientFunds {
account_id,
shortfall: amount - account.balance,
});
}
let charge_id = sql! {
insert into charges(account_id, amount, currency, status, idem_key, created_at)
values (:account_id, :amount, :currency, 'PENDING', :idem_key, :now)
returning id into :out
}.returning::<number>()
.map_err(oracle::DupValOnIndex, |_| DuplicateCharge { idem_key });
// Ledger update — must reconcile.
let new_balance = account.balance - amount;
let updated = sql! {
update accounts
set balance = :new_balance, version = version + 1
where id = :account_id and version = :account.version
}.rowcount();
if updated != 1 {
return Err(LedgerOutOfSync {
account_id,
expected: account.version,
found: account.version, // racy read; real code refetches
});
}
sql! {
update charges set status = 'POSTED', posted_at = :now where id = :charge_id
};
return find_charge(charge_id).expect("just-inserted charge must exist");
} finally {
log::info("post_charge took {span.elapsed()}ms");
// ^^^ this is the foot-gun. If I had written
// log::error("post_charge failed") here by reflex from PL/SQL,
// it would log "failed" on every successful call. The lint
// proposed in §6.3 catches that.
}
// --- batch refund ------------------------------------------------------
@autonomous
pub fn refund_batch(ids: list<number>) -> Result<int, LedgerOutOfSync> {
// Single round-trip; lists pass directly as nested tables.
let n = sql! {
update charges
set status = 'REFUNDED'
where id in (select column_value from table(:ids))
and status = 'POSTED'
}.rowcount();
// Audit trail in the autonomous tx so it survives a rollback above us.
sql! {
insert into refund_audit(refunded_at, count)
values (:now, :n)
};
commit;
return Ok(n);
}
// --- tests -------------------------------------------------------------
@test
fn idempotency_returns_existing_charge() {
// pure-pell test, no DB
// …
}
@test(db)
fn double_post_with_same_idem_key_is_idempotent() {
// requires Oracle 23 connection
// …
}
What the sketch surfaces:
- The
with (…)clause from §4.5 is not used anywhere — every call site already had the locals named correctly. Confirms it doesn’t earn its keep. - I reached for
.one()(not.first(), not.one_or_none()) for the primary read. That confirms.one()should be the documented default. - The
finallyblock at the end ofpost_chargeis exactly where a porting developer would writelog::error("post_charge failed")by muscle memory. The lint proposed in §6.3 (added in this pass) catches that. find_locked(account_id)?propagates an error variant the caller didn’t declare — would be a compile error and force me to addNotFoundto the signature. That’s correct, and the kind of pain that pays off..expect("just-inserted charge must exist")on the post-insert refetch is the canonical “this shouldn’t happen” usage. Reads well at 11pm..returning::<number>()is not in the design doc. I had to make it up. Returning-into for DML is a real PL/SQL pattern and §4.5 doesn’t address it. Gap..rowcount()on asql!{}UPDATE/DELETE is not in the design doc either. PL/SQL’sSQL%ROWCOUNTis essential after every DML. Gap.accounts::find_locked(aSELECT … FOR UPDATE) has no surface in §4.5. Locking semantics forsql!{}are unaddressed. Gap.- Optimistic concurrency (
version = :account.version) works fine but the doc never models it; should.rowcount()checks against expected values get sugar? Probably not, but worth noting. - I didn’t reach for
Option<T>once.T?was always clearer. Confirms the alias-demotion edit.
Proposed edits (made in this worktree)
- §1 (Goals) — replaced
Option<T> (a.k.a. T?)withT?to commit to one spelling in the headline. Added a one-line non-goal: brevity that hurts grep is a regression. - §3 (table) — dropped the “Comments” row (pure churn, doesn’t earn a row). Rewrote the “Compiler hints” row to mention the closed set and validated combinations instead of listing pragma names.
- §4.5 (
sql!{}) — removed thewith (…)clause from the running example. Rewrote the surrounding prose to say binds come from lexical scope, with a one-paragraph note explaining we consideredwith (…)and dropped it as ceremony. - §4.6 (pipelines) — added an honest caveat that until v2 auto-fusion
lands, pipelines over
sql!{}materialize and run in PL/SQL, which is a perf trap. - §5 (type system) — clarified that
T?is the surface form andOption<T>is an internal prelude alias;fmtrewrites the latter. - §5.1.1 (
list<T>) — added a tradeoff note thatxs[i] -> T?makes tight indexed loops more verbose, and steered readers towardfor x in xs/enumerate()as the documented norm. Also fixed an inconsistency where the example usedOption<number>instead ofnumber?after the §5 edit. - §6.3 (finally) — promoted the always-runs foot-gun from a buried
doc note to a named warning section. Proposed a concrete compiler lint
(heuristic regex on error-ish words in
finallybodies that don’t reference the caught error) with an@allow(finally_error_log)opt-out. - §6.5 (terminators) — reordered the table to put
.one()first as the boring default. Added a paragraph explaining when not to reach for.one_or_none(). - §6.5.1 (invariant panics) — added an “Honesty caveat” paragraph
acknowledging that the emitted
RAISE_APPLICATION_ERROR(-20001)is catchable from hand-written PL/SQL upstream. Thepellcompiler can preventpellcode from catching it; it can’t prevent legacy PL/SQL from doing so. - §14 (naming) — collapsed the candidate table to one sentence.
- End-of-doc fluff — removed the “Argue with anything. The §3 table and §6 (errors) are the bits that will shape every subsequent decision.” flourish.
Open questions for Shaun
.returning::<T>()and.rowcount()are missing from §4.5. Every PL/SQL maintainer needsRETURNING … INTOfor inserts andSQL%ROWCOUNTfor DML. What’s the surface? I deliberately didn’t invent one; this is a real gap for M2.SELECT … FOR UPDATE/ locking.sql!{}says nothing about pessimistic locking. The 200-line sketch needed it foraccounts::find_locked. Is locking implicit at the iterator boundary when the SQL containsFOR UPDATE, or is there a separate method?.one_or_none()— keep or cut? I argued for demoting it; would you cut it from v1 entirely and let users compose.one()+.map_err?- Stack traces for
fn returning Unit. PL/SQL distinguishes procedures from functions inFORMAT_CALL_STACK. Does unifying them inpellcost us debugging clarity, and if so, is it worth a flag? @must_usedefault forResult<…>returns. §9.4 says it’s default. Is this also true forsql!{}write statements (whose iterator-shape isResult<Unit, oracle::*>)? If yes, every UPDATE needs a;and a?or explicit discard — confirm.set<T>asmap<T, unit>sugar. Convenient, but iteration order inset<text>will be alphabetical (because of the assoc-array index), which is a surprise nobody asked for. Keep, or use a distinct backing type?finally_bodylowering (§6.3) uses a nested procedure. PL/SQL nested procedures forward-declare; willpellalways emitfinallynested procedures before the body in a way that doesn’t break when the body references locals declared in the same block? Worth a second look in M3.