Sheetshow.Table (Sheetshow v0.1.0)

Copy Markdown View Source

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 live row with this id, if the snapshot has one. Everything else a query might want is Enum over live/1.

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

t()

@type t() :: %Sheetshow.Table{schema: Sheetshow.Schema.t(), sheet: String.t()}

Functions

columns(table)

@spec columns(t()) :: [String.t()]

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"]

compact(snapshot)

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]

create(table)

@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"]

decode(rows, table, opts \\ [])

@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, when rows holds only data. Without it the first row of rows is the header.
  • :row, the sheet row the first given row sits on, so rows know where they are. Defaults to 0 with a header in rows, 1 without.
  • :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)

decode!(rows, table, opts \\ [])

@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
[]

delete(id, opts \\ [])

@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

empty(table)

@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"]

fetch(snapshot, id)

@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

header(table, style \\ %{bold: true})

@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"]]

insert(record, opts \\ [])

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"

live(snapshot)

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"]

new(sheet, schema)

@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"

plan(changes, snapshot)

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.

plan!(changes, snapshot)

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)
[]

read(table, workbook, opts \\ [])

@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))

read!(table, workbook, opts \\ [])

Same as read/3, raising on failure.

refresh(snapshot, workbook)

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.

restore(id)

@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

update(id, record)

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"}