Controller contract anatomy
Dive into into the Tableland controller contract's design.
A controller needs to implement the TablelandController
abstract contract (or the ITablelandController
interface). This enables advanced, custom access control features. Note the default access controls define that only the owner can control everything on the table—this is implemented by default in the TablelandTables
registry contract, so if you don't create/set your own controller, those rules are applied.
Controller anatomy
getPolicy
method
The TablelandController
inherits from the ITablelandController
. It implements a single getPolicy
method that returns a TablelandPolicy
; this is the core of the access control logic.
The getPolicy
method signature is the following:
function getPolicy(address caller, uint256 tableId)
external
payable
virtual
override
returns (TablelandPolicy memory);
Here's how it works:
caller
: The address that is attempting to access the table.- For example, the address calling the registry contract's
create
ormutate
method.
- For example, the address calling the registry contract's
tableId
: The ID of the table that is being mutated.payable
: Used to allow the controller to, optionally, charge a fee for the access control (e.g., require payment to mutate data).
Once you create a controller contract, you then call the setController
method on the TablelandTables
registry contract—see here for more details. This will set the controller for the specified table ID so that it returns a unique policy for the specific table.
TablelandPolicy
struct
The return type for a controller is a TablelandPolicy
, which specifies access control rules for the caller on the table ID:
struct TablelandPolicy {
bool allowInsert;
bool allowUpdate;
bool allowDelete;
string whereClause;
string withCheck;
string[] updatableColumns;
}
It has the following properties:
allowInsert
: Whether or not the table should allow SQLINSERT
statements.allowUpdate
: Whether or not the table should allow SQLUPDATE
statements.allowDelete
: Whether or not the table should allow SQLDELETE
statements.whereClause
: A conditionalWHERE
clause used with SQL UPDATE and DELETE statements.- For example, a value of
"foo > 0"
will concatenate all SQLUPDATE
and/orDELETE
statements with"WHERE foo > 0"
. This can be useful for limiting how a table can be modified. Also, thePolicies
library'sjoinClauses
should be used to include more than one condition.
- For example, a value of
withCheck
: A conditionalCHECK
clause used with SQL INSERT statements.- For example, a value of
"foo > 0"
will concatenate all SQLINSERT
statements with a check on the incoming data. Namely,"CHECK (foo > 0)"
. This can be useful for limiting how table data ban be added. Also, thePolicies
library'sjoinClauses
should be used to include more than one condition.
- For example, a value of
updatableColumns
: A list of SQL column names that can be updated.
Example controller
The following example shows how to create a controller—it imports the TablelandController
contract and implements the getPolicy
method to return a TablelandPolicy
that allows operations by any caller with no restrictions:
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.10 <0.9.0;
import {TablelandController} from "@tableland/evm/contracts/TablelandController.sol";
import {TablelandPolicy} from "@tableland/evm/contracts/TablelandPolicy.sol";
contract Controller is TablelandController {
function getPolicy(
address caller,
uint256
) public payable override returns (TablelandPolicy memory) {
// Return allow-all policy if the caller is the owner
return
TablelandPolicy({
allowInsert: true,
allowUpdate: true,
allowDelete: true,
whereClause: "",
withCheck: "",
updatableColumns: new string[](0)
});
}
}
Policies library
To make it easier to work with more complex WHERE
and WITH CHECK
clauses, the Policies
library provides a joinClauses
method that can be used to concatenate multiple clauses. For example, the following code will return "WHERE foo > 0 AND bar = 1"
:
// Import the Policies library
import {Policies} from "@tableland/evm/contracts/policies/Policies.sol";
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
// ...
// Set up WHERE clause with two conditions
string[] memory whereClause = new string[](2);
uint256 foo = 0;
uint256 bar = 1;
whereClause[0] = string.concat(
"foo > ",
Strings.toString(foo)
);
whereClause[0] = string.concat(
"bar = ",
Strings.toString(bar)
);
string[] memory clauses = Policies.joinClauses(whereClause);
In this example, both foo
and bar
are INTEGER
columns in SQL, and in Solidity, they are uint256
types. We first convert them to a string with the OpenZeppelin Strings
library before concatenating with the clause condition. Note that if you're ever dealing with TEXT
or BLOB
SQL types, they must always be wrapped in single quotes—the SQLHelpers
library provides a nice quote
method for this.
For reference, this is what the library looks like; it simply concatenates the clauses with " and "
:
library Policies {
function joinClauses(
string[] memory clauses
) internal pure returns (string memory) {
bytes memory clause;
for (uint256 i = 0; i < clauses.length; i++) {
if (bytes(clauses[i]).length == 0) {
continue;
}
if (bytes(clause).length > 0) {
clause = bytes.concat(clause, bytes(" and "));
}
clause = bytes.concat(clause, bytes(clauses[i]));
}
return string(clause);
}
}