Skip to content

Commit a4f4cef

Browse files
authored
feat: add lance_dataset_add_columns for SQL / all-null / stream column addition (#45)
## Summary Last of three PRs against #41. Exposes upstream's `Dataset::add_columns` through the three `NewColumnTransform` cases that translate cleanly across the C ABI: - **SQL expressions** — derive new columns from SQL over existing columns. - **All-null columns** — add nullable columns from an Arrow schema. On the modern format this is metadata-only; the legacy format can't represent it that way and returns `LANCE_ERR_NOT_SUPPORTED`. - **Stream** — splice in precomputed column data from an Arrow C stream, aligned positionally to the dataset's existing rows. Upstream's fourth variant, `BatchUDF`, is left out on purpose: it carries a Rust closure that can't cross the C ABI, and the stream variant already covers the same "bring your own computed data" use case. Each call mutates the dataset in place under an exclusive write lock; scanners already in flight keep their pre-add view via the same Arc clone-on-write as the `_drop_columns` (#42) / `_alter_columns` (#44) siblings. Three focused entry points rather than one mode-tagged function, because the inputs are genuinely different shapes (name/expression pairs vs. a schema pointer vs. a stream). Upstream's `read_columns` parameter isn't exposed — it only feeds `BatchUDF`; for these three variants upstream ignores it. `batch_size` is forwarded where it does something (SQL scan, stream alignment) and omitted from the metadata-only all-null path. ## Surface ```c typedef struct LanceSqlColumn { const char* name; const char* expression; } LanceSqlColumn; int32_t lance_dataset_add_columns_sql( LanceDataset* dataset, const LanceSqlColumn* columns, size_t num_columns, uint64_t batch_size); int32_t lance_dataset_add_columns_nulls( LanceDataset* dataset, const struct ArrowSchema* schema); int32_t lance_dataset_add_columns_stream( LanceDataset* dataset, struct ArrowArrayStream* stream, uint64_t batch_size); ``` `batch_size` uses `0` for the upstream default and is range-checked to `u32`. Two error-code details are worth calling out, both matching existing behavior: a SQL expression referencing a non-existent column surfaces as `LANCE_ERR_INTERNAL` (an upstream schema error, the same path as `lance_dataset_delete`), whereas a syntax error is `LANCE_ERR_INVALID_ARGUMENT`. Because these are `unsafe extern "C"` entry points under `panic = "abort"`, the stream variant pre-validates the mandatory CADI callbacks before handing the stream to arrow-rs (which would otherwise abort on a NULL `get_schema` / `get_next`), and the all-null variant rejects an uninitialised or non-UTF-8 top-level schema `format` before arrow-rs's `assert!`/`expect` can fire. The stream is consumed (released) on every non-NULL return path. ## Tests Rust integration tests cover all three variants end to end: computed values (single and multi-column SQL, constant expressions, honored `batch_size`), all-null backfill, and multi-fragment stream alignment — plus the full rejection surface (NULL/empty/non-UTF-8 inputs, name collisions, row-count mismatch, invalid `batch_size`, released/missing-callback streams, non-nullable all-null fields, and the legacy-format `NOT_SUPPORTED` path). Stream-consumption is proven with a drop counter rather than a vacuous release-slot check. C and C++ smoke tests exercise the SQL happy path and each variant's argument rejections across the ABI.
1 parent 68da0b3 commit a4f4cef

7 files changed

Lines changed: 1655 additions & 8 deletions

File tree

include/lance/lance.h

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,114 @@ int32_t lance_dataset_alter_columns(
559559
size_t num_alterations
560560
);
561561

562+
/* ─── lance_dataset_add_columns ───────────────────────────────────────────── */
563+
564+
/**
565+
* A single new column defined by a SQL expression over the dataset's existing
566+
* columns, e.g. { .name = "doubled", .expression = "x * 2" }. Both fields are
567+
* required, non-empty UTF-8, and are read by shared reference for the duration
568+
* of the call.
569+
*/
570+
typedef struct LanceSqlColumn {
571+
/* Name of the new column. Required, non-empty UTF-8. */
572+
const char* name;
573+
/* SQL expression evaluated against existing columns. Required, non-empty. */
574+
const char* expression;
575+
} LanceSqlColumn;
576+
577+
/**
578+
* Add one or more columns computed from SQL expressions over the dataset's
579+
* existing columns, committing a new manifest. Each fragment is scanned, the
580+
* expressions are evaluated, and the results are written as new column files.
581+
*
582+
* Mutates `dataset` in place — the same handle remains valid afterward and
583+
* sees the new version. Scanners already in flight keep their pre-add view.
584+
*
585+
* @param dataset Open dataset (not consumed). Mutated in place. Must not
586+
* be NULL.
587+
* @param columns Array of `LanceSqlColumn`. Must not be NULL; each entry's
588+
* `name` and `expression` must be non-NULL and non-empty.
589+
* @param num_columns Length of `columns`. Must be > 0.
590+
* @param batch_size Rows per scan batch while evaluating expressions.
591+
* 0 = upstream default.
592+
* @return 0 on success, -1 on error. Error codes:
593+
* LANCE_ERR_INVALID_ARGUMENT for NULL/empty inputs, NULL or empty
594+
* `name` / `expression`, non-UTF-8 strings, malformed SQL *syntax*, a
595+
* new column name that collides with an existing column, or a
596+
* `batch_size` beyond UINT32_MAX. An expression that references a
597+
* *non-existent column* surfaces as LANCE_ERR_INTERNAL (an upstream
598+
* schema error, the same path as lance_dataset_delete), not
599+
* LANCE_ERR_INVALID_ARGUMENT. LANCE_ERR_COMMIT_CONFLICT for a
600+
* concurrent writer.
601+
*/
602+
int32_t lance_dataset_add_columns_sql(
603+
LanceDataset* dataset,
604+
const LanceSqlColumn* columns,
605+
size_t num_columns,
606+
uint64_t batch_size
607+
);
608+
609+
/**
610+
* Add one or more all-null columns described by an Arrow C Data Interface
611+
* schema, committing a new manifest. On non-legacy datasets this is a
612+
* metadata-only operation — no data files are rewritten. Every field in the
613+
* schema must be nullable.
614+
*
615+
* Mutates `dataset` in place — the same handle remains valid afterward and
616+
* sees the new version. Scanners already in flight keep their pre-add view.
617+
*
618+
* @param dataset Open dataset (not consumed). Mutated in place. Must not be
619+
* NULL.
620+
* @param schema Arrow C `ArrowSchema` describing the new columns. Read by
621+
* shared reference; its `release` callback is never invoked.
622+
* Must not be NULL. Only the top-level schema is validated
623+
* before it is handed to arrow-rs; the caller is responsible for
624+
* providing fully-initialised child fields.
625+
* @return 0 on success, -1 on error. Error codes:
626+
* LANCE_ERR_INVALID_ARGUMENT for a NULL dataset/schema, an
627+
* uninitialised or already-released schema, an invalid Arrow schema, a
628+
* non-nullable field, or a name that collides with an existing column.
629+
* LANCE_ERR_NOT_SUPPORTED for a legacy-format dataset (which cannot take
630+
* all-null columns as a metadata-only change).
631+
* LANCE_ERR_COMMIT_CONFLICT for a concurrent writer.
632+
*/
633+
int32_t lance_dataset_add_columns_nulls(
634+
LanceDataset* dataset,
635+
const struct ArrowSchema* schema
636+
);
637+
638+
/**
639+
* Add columns by splicing precomputed data from an Arrow C Data Interface
640+
* stream into the dataset, committing a new manifest. The stream's batches are
641+
* consumed in order and aligned positionally to the dataset's existing rows;
642+
* the total row count must match the dataset exactly.
643+
*
644+
* Mutates `dataset` in place — the same handle remains valid afterward and
645+
* sees the new version. Scanners already in flight keep their pre-add view.
646+
*
647+
* @param dataset Open dataset (not consumed). Mutated in place. Must not
648+
* be NULL.
649+
* @param stream Arrow C stream of new column data. When non-NULL it is
650+
* consumed (released) on every return path, including error
651+
* returns — the caller must not use it again. (A NULL stream
652+
* is rejected before anything is consumed.) Its schema
653+
* defines the new columns and must not collide with existing
654+
* column names.
655+
* @param batch_size Rows per write batch while aligning the stream to
656+
* fragments. 0 = upstream default.
657+
* @return 0 on success, -1 on error. Error codes:
658+
* LANCE_ERR_INVALID_ARGUMENT for a NULL dataset/stream, a stream missing
659+
* a mandatory get_schema/get_next/release callback, a stream whose total
660+
* row count does not match the dataset, a new column name that collides
661+
* with an existing column, or a `batch_size` beyond UINT32_MAX.
662+
* LANCE_ERR_COMMIT_CONFLICT for a concurrent writer.
663+
*/
664+
int32_t lance_dataset_add_columns_stream(
665+
LanceDataset* dataset,
666+
struct ArrowArrayStream* stream,
667+
uint64_t batch_size
668+
);
669+
562670
/**
563671
* Export the dataset schema via Arrow C Data Interface.
564672
* @param out Pointer to caller-allocated ArrowSchema struct

include/lance/lance.hpp

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,16 @@ struct ColumnAlteration {
136136
const ArrowSchema* data_type = nullptr;
137137
};
138138

139+
// ─── New column (SQL) ────────────────────────────────────────────────────────
140+
141+
/// A single new column defined by a SQL expression over the dataset's existing
142+
/// columns, added by `Dataset::add_columns_sql`. Both fields are required and
143+
/// non-empty, e.g. `{ "doubled", "x * 2" }`.
144+
struct SqlColumn {
145+
std::string name;
146+
std::string expression;
147+
};
148+
139149
// ─── Dataset ─────────────────────────────────────────────────────────────────
140150

141151
class Dataset {
@@ -511,6 +521,78 @@ class Dataset {
511521
}
512522
}
513523

524+
/// Add columns computed from SQL expressions over the dataset's existing
525+
/// columns, committing a new manifest. `batch_size = 0` uses the upstream
526+
/// default scan batch size.
527+
///
528+
/// `columns` must be non-empty and each entry's `name` and `expression`
529+
/// must be non-empty. Throws lance::Error on failure (empty list, empty
530+
/// name/expression, malformed SQL syntax, name collision with an existing
531+
/// column, commit conflict, ...). A reference to a non-existent column
532+
/// throws with code `LANCE_ERR_INTERNAL` (an upstream schema error), not
533+
/// `LANCE_ERR_INVALID_ARGUMENT` — see the C header for the rationale.
534+
void add_columns_sql(const std::vector<SqlColumn>& columns,
535+
uint64_t batch_size = 0) {
536+
// The C strings we install in each entry borrow from `columns` (the
537+
// caller's std::strings), which outlive this call. The entries are
538+
// copied by value into `raw`, so any reallocation during push_back
539+
// just moves the raw bytes — pointer values are preserved.
540+
std::vector<LanceSqlColumn> raw;
541+
raw.reserve(columns.size());
542+
for (const auto& c : columns) {
543+
LanceSqlColumn entry{};
544+
entry.name = c.name.c_str();
545+
entry.expression = c.expression.c_str();
546+
raw.push_back(entry);
547+
}
548+
// Pass `raw.data()` unconditionally — matches the `alter_columns` and
549+
// `drop_columns` siblings whose inputs are also required to be
550+
// non-empty. An empty `columns` yields `num_columns == 0`, which the
551+
// Rust layer rejects before it indexes the pointer.
552+
if (lance_dataset_add_columns_sql(
553+
handle_.get(), raw.data(), raw.size(), batch_size) != 0) {
554+
check_error();
555+
}
556+
}
557+
558+
/// Add all-null columns described by an Arrow schema, committing a new
559+
/// manifest. Metadata-only on non-legacy datasets. Every field in `schema`
560+
/// must be nullable. The caller owns `schema` and must keep it alive for
561+
/// the duration of the call; the wrapper does not release it.
562+
///
563+
/// Throws lance::Error on failure (invalid schema, non-nullable field, name
564+
/// collision with an existing column, commit conflict, ...). A legacy-format
565+
/// dataset throws with code `LANCE_ERR_NOT_SUPPORTED` (all-null columns are
566+
/// metadata-only and the legacy format cannot represent them that way).
567+
void add_columns_nulls(const ArrowSchema* schema) {
568+
if (lance_dataset_add_columns_nulls(handle_.get(), schema) != 0) {
569+
check_error();
570+
}
571+
}
572+
573+
/// Add columns by splicing precomputed data from an Arrow C stream into the
574+
/// dataset, committing a new manifest. `batch_size = 0` uses the upstream
575+
/// default. When non-null, `stream` is consumed (released) on every return
576+
/// path — including a null-dataset error and when this method throws — so do
577+
/// not use it again afterward. Only a null `stream` is rejected without
578+
/// consuming anything.
579+
///
580+
/// The stream's total row count must match the dataset exactly. Throws
581+
/// lance::Error on failure (row-count mismatch, name collision with an
582+
/// existing column, commit conflict, ...).
583+
void add_columns_stream(ArrowArrayStream* stream, uint64_t batch_size = 0) {
584+
// Forward `stream` straight to the C API, which owns the stream and
585+
// releases it on every path. No RAII guard is needed here (unlike
586+
// `write`, which builds vectors before its C call): nothing between this
587+
// method's entry and the call below can throw, so the stream can never
588+
// be stranded by an exception. WARNING: do not add any throwing code
589+
// before the C call without first arming a stream-release guard.
590+
if (lance_dataset_add_columns_stream(
591+
handle_.get(), stream, batch_size) != 0) {
592+
check_error();
593+
}
594+
}
595+
514596
/// Export the schema as an Arrow C Data Interface struct.
515597
void schema(ArrowSchema* out) const {
516598
if (lance_dataset_schema(handle_.get(), out) != 0) {

0 commit comments

Comments
 (0)