Sheetshow treats a Google spreadsheet as a list of cells: pure functions turn what we want into
a plan, and one function carries the plan out. The same code runs against an .xlsx file and
against an in-memory spreadsheet. This file is the short set of rules that are not guessable
from the function names, in the usage_rules convention, so that a consuming project can sync
it into an agent's context. The cheatsheet is every function on
one page.
The Shape
Cells → plan → run. Sheetshow.plan/2 turns cells into a list of ops and reaches nothing;
Sheetshow.run/2 carries the plan out and is the only function in the library that writes.
One plan is one request, whatever it holds.
cells = Sheetshow.row(["Item", "Cost"], sheet: "Costs")
plan = Sheetshow.plan!(cells, existing_sheets: Sheetshow.Workbook.titles(workbook))
{:ok, workbook} = Sheetshow.run(plan, workbook):existing_sheets is what makes a plan add a tab. Without it, a cell on a sheet the plan
has not been told about is written as if the sheet were there, and Google refuses. Pass the
workbook's titles, and the AddSheet rides in the same request as the first cells.
Keep the workbook run/2 gives back. It knows the sheets the plan created, and on Google
it carries the token and any rotated refresh token. A workbook is a value; where it lives is
the application's business.
Every function that can fail returns {:ok, value} | {:error, %Sheetshow.Error{}}, and
has a ! twin of the same arity that raises. Match on error.reason, an atom, never on the
message; Sheetshow.Error's moduledoc lists every reason.
Connecting
account = Sheetshow.ServiceAccount.from_file!("service-account.json")
{:ok, workbook} = id |> Sheetshow.Workbook.google(credentials: account) |> Sheetshow.connect()
{:ok, workbook} = "costs.xlsx" |> Sheetshow.Workbook.xlsx(create: true) |> Sheetshow.connect()
{:ok, workbook} = Sheetshow.Workbook.memory() |> Sheetshow.connect()Sheetshow.connect/1 is the one setup call, once per workbook: on Google it is a token and a
metadata request, and it authenticates only when there is no good token already. It takes a
%Sheetshow.Workbook{}, never a %Sheetshow.Client{}.
A token lasts an hour, and Sheetshow does not renew it for you. Sheetshow.Client.ready?/2
says whether the workbook's token is still good; Sheetshow.authenticate/1 gets another. A
request made with a dead token is {:error, %Sheetshow.Error{reason: :http}} with status 401.
Sheetshow never looks for a credential. No well-known path, no environment variable, no
cache. Read the file, hold the value. Sheetshow.ServiceAccount.from_file!/1 and
Sheetshow.UserAccount.from_file!/1 are the two readers; Sheetshow.OAuth is the consent
flow, minus the browser and the loopback listener, which are the application's.
Two Reads
{:ok, cells} = Sheetshow.read_cells("Costs!A1:C10", workbook) # everything about each cell
{:ok, rows} = Sheetshow.read_rows("Costs!A1:C10", workbook) # values, at a twelfth the bytes
{:ok, [rows_a, rows_b]} = Sheetshow.read_rows(["Costs!A1:C10", "Costs!E1:E10"], workbook)A range must name a sheet. "A1:C10" alone is :invalid_range. A %Sheetshow.Range{} or
an A1 string works everywhere a range is taken; Sheetshow.Range.from_a1/1 parses one.
read_cells/2 gives what was written; read_rows/2 gives what it works out to. A formula
comes back as {:formula, "=SUM(B2:B4)"} from the first and as its result from the second; a
formula error comes back as text ("#DIV/0!") from the second. A list of ranges to
read_rows/2 is one request.
Values
- A
:decimalcolumn is a string on both sides.Decimal.to_string/1in,Decimal.new/1out; no float ever touches it. Sheetshow does not depend onDecimal. - A
Datecomes back aDate; aDateTimecomes back aNaiveDateTime. The zone is dropped on the way in, so store UTC or store the zone in another column. - A formula is
{:formula, "=..."}. A plain string starting with=is written as text. - A style is a map, merged by
Sheetshow.put_style/2;Sheetshow.Style.keys/0lists the keys. Colours are"#RRGGBB". - Empty cells are left out of a read, so
length(cells)counts written cells, not the rectangle;Sheetshow.to_rows/1puts the gaps back asnil.
The Database on a Tab
Sheetshow.Log and Sheetshow.Table share Sheetshow.Schema, a keyword list of column names
to types (Sheetshow.Schema.types/0 lists them). Writes are strict, so a wrong type or an
unknown column is refused before any request; reads are lenient, so a cell that will not
cast reads as nil with an error on the row, and strict: true refuses the read instead.
More than one writer, use the Log. One writer, either. Google has no conditional write, so
two processes updating rows in place are last-write-wins. A Log append resolves server-side,
so appenders cannot land on each other, and a failed append is safe to send again: the
duplicate ids fold away.
log = Sheetshow.Log.new("expenses", item: :string, cost: :decimal)
event = Sheetshow.Log.Event.new(%{item: "Rent", cost: "1000.00"})
{:ok, workbook} = Sheetshow.run(Sheetshow.Log.create(log) ++ Sheetshow.Log.plan!([event], log), workbook)
{:ok, events} = Sheetshow.Log.read(log, workbook)
state = Sheetshow.Log.fold(events) # latest event per id, tombstones goneAn update is Sheetshow.Log.Event.put/2 appended again; a delete is Event.delete/1.
Sheetshow.Log.read/3 with after: last_event reads only what came since, one request;
{:error, %{reason: :moved}} means the rows above the cursor changed, so read whole.
The Table cycle is read → change → refresh → plan → run.
table = Sheetshow.Table.new("expenses", item: :string, cost: :decimal)
{:ok, snapshot} = Sheetshow.Table.read(table, workbook)
changes = [Sheetshow.Table.update(id, %{cost: "1200.00"}), Sheetshow.Table.delete(other_id)]
{:ok, snapshot} = Sheetshow.Table.refresh(snapshot, workbook)
{:ok, workbook} = changes |> Sheetshow.Table.plan!(snapshot) |> Sheetshow.run(workbook)Changes name rows by id, never by position; refresh/2 is one small read that finds every
row where it is now, so a row somebody inserted by hand does not make us overwrite the wrong
one. delete/2 is soft by default (hard: true removes the row), Sheetshow.Table.live/1
hides the tombstones, and Sheetshow.Table.compact/1 is the plan that removes them all. A
plan of nothing but insert/2 is safe against a snapshot of any age, including
Sheetshow.Table.empty/1.
Backends Say What They Cannot Do
Sheetshow.Backend.capabilities/1 is a map of promises: :atomic_batch,
:conditional_write, :dimensions, :evaluates_formulas, :server_side_append, :styles.
Sheetshow.Backend.supports?/2 asks for one. Rely on a capability and the backend lacks it,
and the answer is {:error, %Sheetshow.Error{reason: :unsupported}} rather than a wrong result.
- Google: everything but
:conditional_write. - An
.xlsxfile: nothing is worked out, so a formula reads back as itself andSheetshow.Log.view/2cannot keep a view current; there is no:server_side_append, since the whole file is written; a WebDAV store gives:conditional_write, a local file does not. - Memory: the test double. Same code, no network; it works out no formulas either.
The Quota
Google allows 60 reads and 60 writes a minute per user, one unit per request however much
it carries. connect/1 is two requests; run/2, read_cells/2, read_rows/2 (with any
number of ranges), Table.read/3, Table.refresh/2 and Log.read/3 are one each. Batch: a
loop that writes a row at a time is unusable, a plan of five hundred rows is one request. Over
the limit is {:error, %Sheetshow.Error{reason: :rate_limited}} with details.retry_after in
seconds when Google named one. Retrying and backing off are the application's.
Testing Without Google
Sheetshow.Workbook.memory/1 is a full backend, so application code runs against it unchanged,
and a plan is a list of structs, so the cheapest test asserts on the plan and runs nothing:
plan = Sheetshow.plan!(cells, existing_sheets: [])
assert [%Sheetshow.Op.AddSheet{title: "Costs"}, %Sheetshow.Op.PutCells{} | _] = planThings Sheetshow Deliberately Does Not Do
- No processes, no configuration, no macros. Nothing to supervise, nothing to
use, and nothing read fromconfig/. A schema is a keyword list; a query isEnum. - No retries, backoff, token cache or scheduling. Each is the application's, which is the only thing that knows what it should be.
- No Drive. A workbook is an existing spreadsheet's id; Sheetshow creates tabs, not
spreadsheets, and needs only the
spreadsheetsscope. - No conditional write on Google, because the API has none; the library makes re-reading
cheap instead of pretending the gap is closed.
guides/guarantees.mdis the whole argument. - No dependencies. Elixir 1.18 or later for
JSON;:public_key,:inets,:ssland:xmerlfrom OTP.