When using a database, you may need to run multiple operations (adding, updating, and/or deleting data) successfully - or none at all. Data Connect already supported transactions, and we’re excited to announce additional support for transactions where now, you can use queried data output from a previous step within a transaction. In other words, you can do the following within a transaction:
- Execute a series of operations
- Query for data
- Use queried data output for another series of operations
For this blog post, let’s say you’re creating a banking app and need to create a transaction log. The transaction log shows the bank account that money was taken from and put into, the amount transferred, and the running balance for both accounts after the transaction. The schema looks like this:
type BankAccount @table(key: "id") {
id: UUID! @default(expr: "uuidV4()")
balance: Int! @default(value: 0)
}
type BankingTransaction @table(key: "id") {
id: UUID! @default(expr: "uuidV4()")
to: UUID! # BankAccount id of the account receiving money
balanceTo: Int! # balance after transaction for above account
from: UUID! # BankAccount id of the account sending money
balanceFrom: Int! # balance after transaction for above account
amount: Int! # transferred amount
timestamp: Timestamp!
}
Previously
You could execute a transaction on mutations by using the @transaction
directive. These mutations are guaranteed to either fully succeed or fully fail, and if any of the fields within the transaction fails, the entire transaction is rolled back. From a client standpoint, any transaction failure behaves as if all fields had failed with a request error.
With the banking example, the app needs to ensure that the amount of money in one account decreases while the other increases in exactly the same (absolute) amount - and they can only both succeed. If either one fails, both fail. To do so, use a transaction where a) money gets taken out of one account and b) money gets put into another account. In the same transaction, we also need to query the balance of both accounts so we can log them later.
mutation TransferMoney(
$from: UUID!
$to: UUID!
$amount: Int!
) @transaction {
# Step 1: take money out from $from account
takeMoney: bankAccount_update(id: $from,
data: {
balance_update: {dec: $amount}
}
)
# Step 2: add money to $to account
addMoney: bankAccount_update(id: $to,
data: {
balance_update: {inc: $amount}
}
)
# Step 3: get balance for $to account
balanceTo: query {
bankAccount(id: $to) { balance }
}
# Step 4: get balance for $from account
balanceFrom: query {
bankAccount(id: $from) { balance }
}
}
Once the entire transaction begins, it will only succeed if all steps complete successfully. Otherwise, it will fail and return execution errors.
Now that we have transferred money between the accounts, the app needs to create the transaction log. To do so, we’ll have to use the balanceTo
and balanceFrom
from the first mutation, which we have to get from client-side code:
export const handleGetBalanceToAndFrom = async (
balanceFrom: int, balanceTo: int,
) => {
try {
const response = await transferMoney({ from, to, amount, time: Date.now() });
const balanceFrom = response.data.balanceFrom
const balanceTo = response.data.balanceTo
// use balanceFrom and balanceTo
} catch (error) {
console.error("Error fetching balances:", error);
}
};
With the balance of both both accounts, we can add an entry for logging purposes:
mutation AddBankingTransaction(
$to: UUID!
$balanceTo: Int!
$from: UUID!
$balanceFrom: Int!
$amount: Int!) {
bankingTransaction_insert(data: {
to: $to,
balanceTo: $balanceTo
from: $from,
balanceFrom: $balanceFrom
amount: $amount
timestamp_expr: "request.time"
}
)
}
New Updates
Taking a look at the example above: what if instead of needing to get the balances from two queries in the client code then passing it into another mutation, you can get the balances and directly use them to insert for the transactions log in the same transaction?
You can now do so, thanks to the new Data Connect updates where you can use information from one step inside a transaction in the next step.
The above example now becomes:
mutation TransferMoney(
$from: UUID!
$to: UUID!
$amount: Int!
) @transaction {
# Step 1: take money out from $from account
takeMoney: bankAccount_update(id: $from,
data: {
balance_update: {dec: $amount}
}
)
# Step 2: add money to $to account
addMoney: bankAccount_update(id: $to,
data: {
balance_update: {inc: $amount}
}
)
# Step 3: get balance for $to account
balanceTo: query {
bankAccount(id: $to) { balance }
}
# Step 4: get balance for $from account
balanceFrom: query {
bankAccount(id: $from) { balance }
}
# Step 5: add banking transaction information
bankingTransaction_insert(data: {
to: $to,
balanceTo_expr: "response.balanceTo.bankAccount.balance"
from: $from,
balanceFrom_expr: "response.balanceFrom.bankAccount.balance"
amount: $amount
timestamp_expr: "request.time"
})
}
Because you can now access and use data received from previous steps in the same transaction, all of this can be done with one transaction, simplifying the client-side code. This also makes the system more secure because before, the client could possibly manipulate logging information when sending both account balances back to the server.
In the example above, response
is a new top-level binding in expressions that represents the partial response object, which includes all top-level mutation fields before the current one. We also support <field>_expr
for most scalar types now, which can come in handy when you need a dynamic value from auth.token
, a previous step, or computed.
Hide sensitive information with @redact
A potential security problem with the example before this new transactions feature is that the bank account amounts probably shouldn’t be exposed to the client - the client doesn’t need to know how much money is in either account.
To fix this problem, you can hide the information with @redact
to remove it from the wire (and generated SDKs). In the example above, you can add @redact
on the two query
fields.
The above code becomes:
mutation TransferMoney(
$from: UUID
$to: UUID
$amount: Int
) @transaction {
# Steps 1 and 2 are the same
# Step 3: get balance for $to account
balanceTo: query @redact {
bankAccount(id: $to) { balance }
}
# Step 4: get balance for $from account
balanceFrom: query @redact {
bankAccount(id: $from) { balance }
}
# Step 5 is the same
bankingTransaction_insert(data: {
# other fields
balanceTo_expr: "response.balanceTo.bankAccount.balance"
balanceFrom_expr: "response.balanceFrom.bankAccount.balance"
})
}
Even though the id
field was redacted, the fields are still available in response
for step 5. In other words, you can still use fields in later steps even if you use @redact
on it.
Using response
in @check
There’s another way to use response
: in @check(expr: "...")
! Combined with query { … }
operations in mutations, you can achieve a lot more without any additional client-server roundtrips, and you can use it to build even more complex server-side logic. Best of all, these can happen within the same @transaction
, so you still get the atomicity guarantees as you’d expect from a relational database.
For the banking example, the app should make sure the balance of the account sending money does not go negative. We can add an @check
to ensure this:
mutation TransferMoney(
$from: UUID
$to: UUID
$amount: Int
) @transaction {
# Steps 1-3 are the same
# Step 4: get balance for $from account
balanceFrom: query @check(
expr: "response.balanceFrom.bankAccount.balance < 0",
message: "Balance is negative, not enough money to transfer!")
@redact {
bankAccount($from) { balance }
}
# Step 5 is the same
}
As a side note, @check
has access to response.balanceFrom
because @check
always runs after the step to which it’s attached.
Try it out!
These new features and improvements are designed to unlock new use cases and make Data Connect a more capable database service for your apps. We’re excited to see what you build with it, happy coding!