---
title: "Getting started with icebergr"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{Getting started with icebergr}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r, include = FALSE}
knitr::opts_chunk$set(
  collapse = TRUE,
  comment = "#>"
)
```

Every example here runs offline against a table built on your own machine. No
catalog server, no network, no credentials.

```{r setup}
library(icebergr)
```

## What this package is for

Iceberg is the open table format that Snowflake, Databricks, BigQuery, AWS and
Dremio have all standardised on. Apache maintains clients for Java, Python, Rust
and Go — but not R, which has been able to read Iceberg tables only by routing
through DuckDB. That rules out writes, snapshot management, schema access and
catalog integration.

`icebergr` binds `iceberg-rust` directly. Arrow is the interchange layer, so
scan results arrive in R without a serialisation round trip.

## A table to work with

`icebergr_example_table()` builds a real Iceberg table in a temporary warehouse
directory: two appends, so there is history to travel through and more than one
data file for a filter to prune.

```{r}
tbl <- icebergr_example_table(rows = 200)
tbl
```

It is generated rather than shipped because Iceberg records absolute paths in its
metadata and its Avro manifests — a table built on one machine does not resolve
on another.

## Inspecting a table

```{r}
icebergr_schema(tbl)
```

`type` is the Iceberg type, not the R type. The mapping to R happens on read.

An unpartitioned table returns zero partition fields:

```{r}
icebergr_partitions(tbl)
```

## Reading

`icebergr_scan()` describes a read; `icebergr_collect()` performs it.

```{r}
icebergr_collect(icebergr_scan(tbl, limit = 5))
```

Scanning the whole table is common enough to have a shorthand:

```{r}
nrow(icebergr_collect(tbl))
```

### Pushdown

`filter` and `select` are pushed into scan planning. This is the entire
performance argument for Iceberg over reading raw Parquet: manifests carry
per-file statistics, so whole files and row groups are eliminated before any
bytes are read.

```{r}
icebergr_collect(
  icebergr_scan(
    tbl,
    filter = id > 1000 & amount > 900,
    select = c("id", "event", "amount")
  )
)
```

Filters may use `==`, `!=`, `<`, `<=`, `>`, `>=`, `&`, `|`, `!`, `%in%`,
`is.na()`, `is.nan()` and `startsWith()` -- the last only against a `string`
column, since Iceberg defines a prefix comparison for no other type. A bare name
is read as a column when the table has one of that name, and otherwise evaluated
in the calling environment:

```{r}
# The second append holds ids 1001 to 1200, so this keeps the last fifty.
threshold <- 1150
icebergr_collect(icebergr_scan(tbl, filter = id > threshold, select = "id"))
```

Anything more elaborate is refused rather than quietly ignored, so you always
know whether a filter was pushed down:

```{r, error = TRUE}
icebergr_collect(icebergr_scan(tbl, filter = sqrt(amount) > 10))
```

### Verifying that pushdown happened

Comparing results proves nothing: a filter applied in R afterwards gives the same
rows. What distinguishes pushdown is how much was planned to be read.

```{r}
icebergr_scan_plan(icebergr_scan(tbl))[, c("record_count", "file_size_in_bytes")]
```

```{r}
icebergr_scan_plan(icebergr_scan(tbl, filter = id > 1000))[, c("record_count")]
```

Fewer files, and fewer records, than the table holds.

### One caveat about `limit`

`limit` is **not** pushdown. `iceberg-rust` has no row limit in its scan API, so
the same files are planned and rows are counted as batches arrive. It bounds how
much is decoded, not how much is planned. `print()` says so:

```{r}
icebergr_scan(tbl, filter = id > 1000, limit = 10)
```

## Time travel

Every write creates a snapshot.

```{r}
history <- icebergr_snapshots(tbl)
history[, c("snapshot_id", "operation", "added_records", "total_records")]
```

Snapshot ids are **character**, not numeric. Iceberg assigns them as random
64-bit integers, and an R numeric carries only 53 bits, so passing one through a
double would silently select the wrong snapshot.

Read an earlier state by id:

```{r}
nrow(icebergr_collect(icebergr_scan(tbl, snapshot_id = history$snapshot_id[[1]])))
```

Or by time, which is resolved against the history to the snapshot that was
current at that moment:

```{r}
nrow(icebergr_collect(icebergr_scan(tbl, as_of = history$timestamp[[1]])))
```

Iceberg records a schema per snapshot, so `filter` and `select` are resolved
against the schema of the snapshot actually being read rather than the current
one. A column another engine has since renamed or dropped is therefore still
nameable as of the snapshot that had it, and `icebergr_schema()` will show you
what those columns were:

```{r}
icebergr_schema(tbl, snapshot_id = history$snapshot_id[[1]])
```

## Writing

Writes are append-only. Nothing already in the table is rewritten or removed.

```{r}
new_rows <- data.frame(
  id = c(9001L, 9002L),
  event = c("purchase", "refund"),
  amount = c(42.5, -12.25),
  day = as.Date(c("2024-07-01", "2024-07-02")),
  recorded_at = as.POSIXct(c("2024-07-01 09:00:00", "2024-07-02 10:30:00"), tz = "UTC")
)

tbl <- icebergr_append(tbl, new_rows)
nrow(icebergr_collect(tbl))
```

`icebergr_append()` returns an *updated* handle rather than mutating the old one,
so reassign it. The old handle still reads the older snapshot, which is
occasionally useful and never surprising.

Columns are matched by name, not position, so order does not matter. A column the
table does not have is an error rather than a silent drop:

```{r, error = TRUE}
icebergr_append(tbl, transform(new_rows, unexpected = 1))
```

### Creating a table

A data frame is enough to define a schema. Only the column names and types are
used; no rows are written.

```{r}
warehouse <- tempfile("warehouse")
dir.create(warehouse)

catalog <- icebergr_catalog("memory", warehouse = warehouse)
icebergr_create_namespace(catalog, "analytics")

measurements <- data.frame(
  sensor = character(),
  reading = double(),
  taken_at = as.POSIXct(character(), tz = "UTC")
)

sensors <- icebergr_create_table(catalog, "analytics.measurements", measurements)
icebergr_schema(sensors)
```

## Type fidelity

Most R types survive unchanged. The exception worth knowing is `factor`: Iceberg
has no dictionary type, so levels cannot be carried and values come back as
`character`.

```{r}
types <- data.frame(
  i = 1L,
  d = 1.5,
  s = "text",
  b = TRUE,
  day = as.Date("2024-01-01"),
  ts = as.POSIXct("2024-01-01 12:00:00", tz = "UTC"),
  f = factor("a", levels = c("a", "b"))
)

type_tbl <- icebergr_create_table(catalog, "analytics.types", types)
type_tbl <- icebergr_append(type_tbl, types)

vapply(icebergr_collect(type_tbl), function(x) class(x)[[1]], character(1))
```

## Knowing what is not supported

`icebergr` 0.1.0 is deliberately narrow. Rather than discovering a gap at
runtime, ask:

```{r}
support <- icebergr_spec_support()
support$spec_versions
```

```{r}
features <- support$features
unsupported <- !is.na(features$supported) & !features$supported
features[unsupported, c("feature", "reason")]
```

Some of those are absent from `iceberg-rust` itself, not just from this package;
the `reason` column says which.

## Where next

`vignette("catalog-configuration")` covers connecting to REST catalogs, AWS Glue
and object storage, and how credentials are handled.
