A tab of mutable rows, and the second of the two ways to keep data on a sheet.
A table's rows are where a log's are events: row 7 is the record, and changing it means writing over row 7. That is the model people expect from a spreadsheet, and the one Google gives no conditional write for; see What Sheetshow Can Promise. Everything here is arranged around that fact rather than around hiding it.
iex> alias Sheetshow.Table
iex> table = Table.new("costs", item: :string, cost: :decimal)
iex> rows = [
...> ["id", "deleted", "item", "cost"],
...> ["a", nil, "Rent", "1000.00"],
...> ["b", true, "Food", "400.00"]
...> ]
iex> {:ok, snapshot} = Table.decode(rows, table)
iex> Table.live(snapshot) |> Enum.map(&{&1.id, &1.row, &1.record.item})
[{"a", 1, "Rent"}]What you hold between reading and writing is a Sheetshow.Table.Snapshot: the
rows, and the row each of them sat on. A write is planned against a snapshot,
so a snapshot is also the thing that goes stale: someone inserting a row by
hand moves every row under it, and a plan made against the old positions would
write to the wrong ones.
The tab is laid out as a log's is. Row 0 is the header (id, deleted, then
the schema's columns), and deleted is blank on a live row. Columns are found
by header name, so a person's own columns to the right of the schema's are
read past and never written over. Unlike a log, the schema's order is not
forced on the tab: rows here are written by index, so the header says where
each column is.
Reading is lenient, as it is everywhere: a cell that will not cast leaves its
field nil and an entry in the row's errors, and strict: true refuses
such a read instead. Two rows sharing an id is the table's own version of
that: the second one is flagged rather than dropped, because it is data
somebody typed.
The cycle
{:ok, snapshot} = Table.read(table, workbook) # one read, the whole tab
changes = [
Table.update(id, %{cost: "1100.00"}),
Table.delete(other_id),
Table.insert(%{item: "Fuel", cost: "50.00"})
]
{:ok, snapshot} = Table.refresh(snapshot, workbook) # one read, the id column
{:ok, workbook} = changes |> Table.plan!(snapshot) |> Sheetshow.run(workbook)There is no verify and no commit: checking a snapshot is a fresh read,
and the library's job is to make that read cheap and re-planning free. A
change names its row by id, so plan/2 is pure and can be run again against
whatever refresh/2 finds, and a cycle costs three requests, or two without
the refresh. The refresh catches rows that have moved; reading again catches a
cell somebody edited under you; nothing catches the gap between the last read
and the write, only a store with a conditional write does.
Queries are Enum over live/1, since schemas are data and there is no
query language here.
Summary
Functions
The columns a new tab is given, left to right. What is on the tab afterwards
is the tab's business; see decode/3.
The plan that takes every soft-deleted row off the tab for good, bottom-up and in one batch. The tidying half of soft delete, for when the tombstones have served their purpose.
The plan that makes the tab and writes its header. Fails at the backend if the
tab is already there, which is what you want from something called create.
A snapshot from the tab's values: rows of cell values, as
Sheetshow.read_rows/2 or Sheetshow.to_rows/1 gives them.
Same as decode/3, raising on failure.
Takes a row out. By default that means setting its deleted flag: one cell,
nothing below it moves, and a plan aimed at a row that has since shifted
leaves a flag in the wrong place rather than destroying a record.
The snapshot of a tab that has just been made: the header create/1 writes,
and no rows.
The header as cells, bold unless you say otherwise.
A row to add, with an id of its own unless you pass one. Takes :id, any
non-empty string, exactly as a log's events do.
The rows a reader would call the table's contents: the ones not tombstoned.
A table on a tab, with the columns its rows have. Raises ArgumentError on a
schema Sheetshow.Schema.validate/1 refuses.
The plan that carries out these changes against the tab as this snapshot found it.
Same as plan/2, raising on failure.
The tab as it stands, as a snapshot. Takes decode/3's :strict.
Same as read/3, raising on failure.
The same snapshot, with the rows found where they are now: one request for the header row and the id column, asked for together.
Puts a soft-deleted row back: empties its deleted flag. Nothing a hard
delete took can be restored.
A change to the columns it names on the row with this id, leaving every other cell on that row as it is.
Types
@type t() :: %Sheetshow.Table{schema: Sheetshow.Schema.t(), sheet: String.t()}
Functions
The columns a new tab is given, left to right. What is on the tab afterwards
is the tab's business; see decode/3.
iex> Sheetshow.Table.new("costs", item: :string) |> Sheetshow.Table.columns()
["id", "deleted", "item"]
@spec compact(Sheetshow.Table.Snapshot.t()) :: Sheetshow.Op.plan()
The plan that takes every soft-deleted row off the tab for good, bottom-up and in one batch. The tidying half of soft delete, for when the tombstones have served their purpose.
Nothing can go wrong in the making of it, so it is a plan rather than an
{:ok, plan}. But it is a hard delete, and a stale snapshot means deleting
the wrong rows. Refresh, or read again, immediately before.
iex> table = Sheetshow.Table.new("costs", item: :string)
iex> rows = [["id", "deleted", "item"], ["a", nil, "Rent"], ["b", true, "Food"]]
iex> plan = Sheetshow.Table.decode!(rows, table) |> Sheetshow.Table.compact()
iex> Enum.map(plan, & &1.rows)
[2..2]
@spec create(t()) :: Sheetshow.Op.plan()
The plan that makes the tab and writes its header. Fails at the backend if the
tab is already there, which is what you want from something called create.
iex> Sheetshow.Table.new("costs", item: :string)
...> |> Sheetshow.Table.create()
...> |> Enum.map(&Sheetshow.Op.sheet/1)
["costs", "costs"]
@spec decode([[term()]], t(), keyword()) :: {:ok, Sheetshow.Table.Snapshot.t()} | {:error, Sheetshow.Error.t()}
A snapshot from the tab's values: rows of cell values, as
Sheetshow.read_rows/2 or Sheetshow.to_rows/1 gives them.
Options:
:header, the header row, whenrowsholds only data. Without it the first row ofrowsis the header.:row, the sheet row the first given row sits on, so rows know where they are. Defaults to0with a header inrows,1without.:strict, to refuse the read when any cell would not cast or any id is missing or repeated, rather than flagging the row. Off by default.
Columns are matched by header name, ignoring case and surrounding space, so a column that has been renamed fails loudly rather than reading as the one next to it. Blank rows are skipped, and the rows that are left keep the sheet rows they came from, which is what a write later needs.
iex> table = Sheetshow.Table.new("costs", item: :string)
iex> rows = [["id", "deleted", "item"], ["a", nil, "Rent"], ["a", nil, "Food"]]
iex> {:ok, snapshot} = Sheetshow.Table.decode(rows, table)
iex> Enum.map(snapshot.rows, &{&1.row, Map.has_key?(&1.errors, :id)})
[{1, false}, {2, true}]
iex> table = Sheetshow.Table.new("costs", cost: :integer)
iex> {:error, %Sheetshow.Error{reason: :missing_column}} =
...> Sheetshow.Table.decode([["id", "deleted", "price"]], table)
@spec decode!([[term()]], t(), keyword()) :: Sheetshow.Table.Snapshot.t()
Same as decode/3, raising on failure.
iex> table = Sheetshow.Table.new("costs", item: :string)
iex> Sheetshow.Table.decode!([["id", "deleted", "item"]], table).rows
[]
@spec delete(String.t(), keyword()) :: Sheetshow.Table.Change.t()
Takes a row out. By default that means setting its deleted flag: one cell,
nothing below it moves, and a plan aimed at a row that has since shifted
leaves a flag in the wrong place rather than destroying a record.
hard: true plans a real Sheetshow.Op.DeleteRows instead. That is the
destructive one, since it takes the whole row, a person's own columns
included, and shifts everything below, so it is worth being sure the snapshot
is fresh. compact/1 is the usual way to want this.
iex> Sheetshow.Table.delete("a").hard
false
@spec empty(t()) :: Sheetshow.Table.Snapshot.t()
The snapshot of a tab that has just been made: the header create/1 writes,
and no rows.
It is what lets a table be created and filled in one request, with nothing to
read first: an insert does not depend on where anything sits, so a snapshot
of an empty tab is all plan/2 needs to place one.
plan = Sheetshow.Table.create(table) ++ Sheetshow.Table.plan!(rows, Sheetshow.Table.empty(table))
{:ok, workbook} = Sheetshow.run(plan, workbook)Only for a tab you are making in the same breath. For one that is already
there, read/3: its header is whatever is on it.
iex> table = Sheetshow.Table.new("costs", item: :string)
iex> Sheetshow.Table.empty(table).header
["id", "deleted", "item"]
@spec fetch(Sheetshow.Table.Snapshot.t(), String.t()) :: {:ok, Sheetshow.Table.Row.t()} | :error
The live row with this id, if the snapshot has one. Everything else a query
might want is Enum over live/1.
iex> table = Sheetshow.Table.new("costs", item: :string)
iex> rows = [["id", "deleted", "item"], ["a", nil, "Rent"], ["b", true, "Food"]]
iex> snapshot = Sheetshow.Table.decode!(rows, table)
iex> {:ok, row} = Sheetshow.Table.fetch(snapshot, "a")
iex> {row.record.item, row.row}
{"Rent", 1}
iex> Sheetshow.Table.fetch(snapshot, "b")
:error
@spec header(t(), Sheetshow.Style.t()) :: [Sheetshow.Cell.t()]
The header as cells, bold unless you say otherwise.
iex> Sheetshow.Table.new("costs", item: :string)
...> |> Sheetshow.Table.header()
...> |> Sheetshow.to_rows()
[["id", "deleted", "item"]]
@spec insert(Sheetshow.Schema.fields(), keyword()) :: Sheetshow.Table.Change.t()
A row to add, with an id of its own unless you pass one. Takes :id, any
non-empty string, exactly as a log's events do.
An insert is the one change that does not depend on where anything sits:
appendCells resolves below the last row with data when Google applies it,
not when the plan is made, so a plan of nothing but inserts is safe against a
snapshot of any age.
iex> Sheetshow.Table.insert(%{item: "Fuel"}, id: "f").id
"f"
@spec live(Sheetshow.Table.Snapshot.t()) :: [Sheetshow.Table.Row.t()]
The rows a reader would call the table's contents: the ones not tombstoned.
snapshot.rows is everything, tombstones included, for when what was taken
out matters.
iex> table = Sheetshow.Table.new("costs", item: :string)
iex> rows = [["id", "deleted", "item"], ["a", nil, "Rent"], ["b", true, "Food"]]
iex> Sheetshow.Table.decode!(rows, table) |> Sheetshow.Table.live() |> Enum.map(& &1.id)
["a"]
@spec new(String.t(), Sheetshow.Schema.t()) :: t()
A table on a tab, with the columns its rows have. Raises ArgumentError on a
schema Sheetshow.Schema.validate/1 refuses.
iex> Sheetshow.Table.new("costs", item: :string, cost: :decimal).sheet
"costs"
@spec plan([Sheetshow.Table.Change.t()], Sheetshow.Table.Snapshot.t()) :: {:ok, Sheetshow.Op.plan()} | {:error, Sheetshow.Error.t()}
The plan that carries out these changes against the tab as this snapshot found it.
Pure, and strict as writing always is: a value that does not fit the schema, a column the schema has not got, an id the snapshot cannot place, or two changes to one id are all errors here rather than surprises in the sheet. An empty list is an empty plan.
iex> alias Sheetshow.Table
iex> table = Table.new("costs", item: :string, cost: :decimal)
iex> rows = [["id", "deleted", "item", "cost"], ["a", nil, "Rent", "1000.00"]]
iex> snapshot = Table.decode!(rows, table)
iex> {:ok, plan} = [Table.update("a", %{cost: "1100.00"})] |> Table.plan(snapshot)
iex> Enum.map(plan, &Sheetshow.Op.PutCells.range(&1)) |> Enum.map(&Sheetshow.Range.to_a1/1)
["costs!A2", "costs!D2"]Two writes for one update, because the columns are not next to each other and
a Sheetshow.Op.PutCells is a run without gaps: the cost cell, and the id
cell. An update always writes its row's id back. It costs nothing (a plan
is one request however many ops it holds) and it is what makes a write that
landed on the wrong row visible: the tab then has that id twice, and the next
read flags it, where otherwise a record would have been quietly overwritten
and still look fine.
The order the ops go in
Updates first, then the one Sheetshow.Op.AppendRows that carries every
insert, then any hard deletes, last and bottom-up. Deleting a row moves
everything under it, so row numbers taken from the snapshot are only true
before the first delete applies; putting them last and in descending order is
what keeps the rest of the plan aimed at the rows it was aimed at. That is the
planner's job, not yours.
The plan is applied whole or not at all. The gap between the read the snapshot
came from and the write is not covered: refresh/2 makes it short, and only
a store with a conditional write closes it.
@spec plan!([Sheetshow.Table.Change.t()], Sheetshow.Table.Snapshot.t()) :: Sheetshow.Op.plan()
Same as plan/2, raising on failure.
iex> table = Sheetshow.Table.new("costs", item: :string)
iex> snapshot = Sheetshow.Table.decode!([["id", "deleted", "item"]], table)
iex> Sheetshow.Table.plan!([], snapshot)
[]
@spec read(t(), Sheetshow.Workbook.t(), keyword()) :: {:ok, Sheetshow.Table.Snapshot.t()} | {:error, Sheetshow.Error.t()}
The tab as it stands, as a snapshot. Takes decode/3's :strict.
This is Sheetshow.read_rows/2 over the whole tab and then decode/3: the
values endpoint rather than the cell one, because a stored row wants what its
formulas worked out to, and the schema already says which numbers are dates.
See Sheetshow.read_rows/2 for what that costs you.
At Google it is one request whatever the tab holds, its answer trimmed to the rows with something in them. Against a file it is the file: a workbook is read whole before any one tab of it can be.
{:ok, snapshot} = Sheetshow.Table.read(table, workbook)
snapshot |> Sheetshow.Table.live() |> Enum.filter(&(&1.record.cost > 500))
@spec read!(t(), Sheetshow.Workbook.t(), keyword()) :: Sheetshow.Table.Snapshot.t()
Same as read/3, raising on failure.
@spec refresh(Sheetshow.Table.Snapshot.t(), Sheetshow.Workbook.t()) :: {:ok, Sheetshow.Table.Snapshot.t()} | {:error, Sheetshow.Error.t()}
The same snapshot, with the rows found where they are now: one request for the header row and the id column, asked for together.
A plan cannot be made to apply only if the sheet is as you read it; what it can be is recent. A change names its row by id, never by position, so re-planning against fresh positions is pure and free, and one column's worth of bytes turns a snapshot minutes old into one milliseconds old.
{:ok, snapshot} = Sheetshow.Table.refresh(snapshot, workbook)
{:ok, workbook} = changes |> Sheetshow.Table.plan!(snapshot) |> Sheetshow.run(workbook)It catches rows inserted, removed or moved anywhere on the tab. It does not
catch somebody editing a cell of a row you are about to write; only read/3
would, though an update writes only the columns it names, so another column's
change survives anyway. Against a file there is no small read: refresh/2
costs what read/3 costs.
Rows whose id is no longer on the tab drop out, because that is the truth
about the sheet; a change that named one then fails in plan/2 as
:unknown_id. Rows the snapshot never read stay unread, since refreshing is
about where things are, not what they say. If the header itself has changed,
the column positions the snapshot holds are no longer true and you get
{:error, %Sheetshow.Error{reason: :moved}}: read again.
@spec restore(String.t()) :: Sheetshow.Table.Change.t()
Puts a soft-deleted row back: empties its deleted flag. Nothing a hard
delete took can be restored.
iex> Sheetshow.Table.restore("a").action
:restore
@spec update(String.t(), Sheetshow.Schema.fields()) :: Sheetshow.Table.Change.t()
A change to the columns it names on the row with this id, leaving every other cell on that row as it is.
Naming a column with nil empties it; not naming it leaves it alone. So an
update never writes over a column it was not asked about, which is what lets
a person keep working in the same tab.
iex> Sheetshow.Table.update("a", %{cost: "1100.00"}).record
%{cost: "1100.00"}