Skip to content

feat(cubesql): Support COPY ... FROM STDIN - #11538

Open
MazterQyou wants to merge 1 commit into
masterfrom
cubesql/copy-command
Open

feat(cubesql): Support COPY ... FROM STDIN#11538
MazterQyou wants to merge 1 commit into
masterfrom
cubesql/copy-command

Conversation

@MazterQyou

Copy link
Copy Markdown
Member

Check List

  • Tests have been run in packages where changes have been made if available
  • Linter has been run for changed code
  • Tests for the changes have been added if not covered yet
  • Docs have been added / updated if required

Description of Changes Made

This PR adds support for COPY ... FROM STDIN command, allowing adding data to temporary tables. Related tests are included.

Signed-off-by: Alex Qyoun-ae <4062971+MazterQyou@users.noreply.github.com>
@MazterQyou
MazterQyou requested review from a team and keydunov as code owners August 12, 2026 16:28
@mintlify

mintlify Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
cubed3 🟢 Ready View Preview Aug 12, 2026, 4:29 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@github-actions github-actions Bot added the rust Pull requests that update Rust code label Aug 12, 2026
@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Claude finished @MazterQyou's task in 9m 44s —— View job


Review: 2 high, 3 medium, 4 low — 6 inline comments posted

Full review — findings, verification notes, and todo list

Todos

  • Gather context (diff, existing review threads — none found)
  • Review protocol layer (pg-srv/protocol.rs, buffer.rs)
  • Review COPY parsing/planning (compile/copy.rs, router.rs, plan.rs)
  • Review data ingest (sql/postgres/copy.rs, temp_tables.rs, shim.rs)
  • Review tests & docs
  • Post findings

General

This is unusually careful work. The decoder mirrors CopyReadLineText / CopyReadAttributesText / CopyReadAttributesCSV down to error codes, CONTEXT/HINT fields and limit_printout_length; rows are correctly assembled across arbitrary CopyData boundaries and the chunked test helper exercises that; an error mid-copy discards everything so a failed COPY leaves the table untouched; and the reserve rewrite in temp_tables.rs fixes a real pre-existing bug (the old nested fetch_update could double-count the server total on CAS retry) and now rolls the session counter back when the total limit rejects. Comments explain why, not what. Docs are appropriately concise and in the right place.

Findings below; the substantive ones are also inline on the diff.

High

  1. CREATE TEMPORARY TABLE performs its side effect during planningrouter.rs:765. create_empty_table_to_plan calls TempTableManager::save from plan_query, but plan_query also runs on Parse (shim.rs:1127), on Bind (shim.rs:1006), under EXPLAIN, and as a compile-only check for DECLARE. Any driver that uses the extended protocol for DDL creates the table at Parse and then fails at Bind with relation "t" already exists, before Execute. All tests use simple_query, which plans once, so this is uncovered.

  2. Trailing lone backslash drops a field and then spinscopy.rs:245. split_line(0) leaves the backslash in the buffer with scanned reset to 0, so finish() re-reads the same state forever. For a single-column table it appends empty rows until check_memory_limit trips ~2M iterations later and reports a bogus memory-limit error; for wider tables the row loses its trailing backslash. Suggested one-line fix inline.

Medium

  1. parse_decimal rejects valid numeric valuescopy.rs:961. Leading zeros count against precision (007numeric(2,0) rejected) and sub-1 values consume the whole precision (0.05numeric(2,2) rejected). Separately, PG rounds a too-long fraction to the declared scale rather than erroring, which combined with the Decimal(38, 10) default for bare NUMERIC makes >10 decimals an error.

  2. NOT NULL is accepted but never enforcedcopy.rs:447. Unlisted columns are filled with None and \N is appended as-is regardless of the field's nullable flag. Best case the constraint is silently ignored; worst case RecordBatch::try_new fails with an opaque internal error instead of PG's 23502.

  3. FORCE_NOT_NULL / FORCE_NULL names aren't identifier-foldedcompile/copy.rs:289. The COPY column list uses normalize_ident, these use the raw ident.value, so FORCE_NOT_NULL (A) fails with column "A" … does not exist while COPY t (A) works.

Low / nits

  1. timestamptz silently becomes naiverouter.rs:1078; values carrying an offset fail to parse. Inline.

  2. IF NOT EXISTS is silently ignored — the .. in the CreateTable pattern swallows if_not_exists (and column.collation), so CREATE TEMPORARY TABLE IF NOT EXISTS t (…) still errors when t exists.

  3. Decode budget is the full session limit, not the remaining onehandle_copy_in passes TempTableManager::session_memory_limit(), and accepted_bytes / built_bytes are each compared against it independently, so peak transient memory can approach ~2× the configured limit on top of what the session already holds. The real enforcement is the reserve inside append, which only runs after the entire stream has been buffered and built — a copy into a nearly-full session does all the work before failing.

  4. Minor: is_utf8_encoding doesn't accept PG's unicode alias; CopyInResponse::serialize returns None (silently writing no message, hanging the client) when columns exceed i16::MAX, unreachable in practice but an error would be better than a hang; FORCE_NOT_NULL columns aren't required to appear in the copy column list, which PG rejects.

Verification notes

I did not build or run the test suite here (the DataFusion fork build is prohibitively long in this environment) — findings 1–5 come from reading the call graph, and the reasoning for each is spelled out in the inline comments so they can be checked quickly. Findings 1 and 2 are deterministic from the code paths shown rather than heuristic.

· branch `cubesql/copy-command`

Comment on lines +765 to +775

let schema = Arc::new(Schema::new(fields));
let temp_tables = self.state.temp_tables();
tokio::task::spawn_blocking(move || {
temp_tables.save(
&table_name,
TempTable::from_arrow_schema(schema, vec![vec![]]),
)
})
.await
.map_err(|err| CompilationError::internal(err.to_string()))??;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The table is created during planning, which breaks the extended query protocol.

convert_statement_to_cube_queryplan_query is called from three places that are not query execution:

  • prepare_statement (shim.rs:1127) — every Parse message
  • bind (shim.rs:1006) — every Bind message
  • explain_to_plan (router.rs:318) and DECLARE validation (shim.rs:1537), which the comment there explicitly calls "just a verification that we can compile that query"

So for any driver that uses the extended protocol for DDL (JDBC, ODBC, psycopg with server-side prepare, pgx, tokio_postgres::execute), CREATE TEMPORARY TABLE t (n int) is planned at Parse, creating the table, and planned again at Bind, which hits the contains_key check in TempTableManager::save and fails with relation "t" already exists — before Execute ever runs. Same for EXPLAIN CREATE TEMPORARY TABLE …, and a Parse that is never executed still leaves the table behind.

Every test in test_copy.rs uses simple_query, which plans exactly once, so this path is uncovered.

Suggest following the existing CreateTempTable shape: return a plan variant carrying the schema and do the save at execution time (in Portal/write_portal), and add a test that creates the table via client.execute(...)/prepare rather than simple_query.

Comment on lines +245 to +252
let Some(next) = self.peek(self.scanned + 1) else {
if at_end {
// A backslash at the very end escapes nothing
return Ok(Line::Row(self.split_line(0)));
}

return Ok(Line::Incomplete);
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A trailing lone backslash at end of data drops a field and then spins.

split_line(0) here takes buffer[..scanned] and leaves the backslash in the buffer with scanned reset to 0. Two consequences for text data whose last line ends with \ and has no trailing newline (e.g. 1\tone\):

  1. The backslash is silently dropped from the row, even though unescape_text (line 679) correctly treats a trailing backslash as a literal — so the two disagree.
  2. finish()'s while !self.finished loop re-enters take_line(true) with the identical state (scanned == 0, buffer == "\\"), gets Row("") again, and loops. For a single-column table each iteration appends an empty row and bumps built_bytes, so it only terminates ~2M iterations later via check_memory_limit, reporting COPY data exceeds the temporary table memory limit instead of loading the row.

The fix is to let the ordinary "unterminated last row" branch (line 282) take the whole buffer:

Suggested change
let Some(next) = self.peek(self.scanned + 1) else {
if at_end {
// A backslash at the very end escapes nothing
return Ok(Line::Row(self.split_line(0)));
}
return Ok(Line::Incomplete);
};
let Some(next) = self.peek(self.scanned + 1) else {
if at_end {
// A backslash at the very end escapes nothing
self.scanned += 1;
continue;
}
return Ok(Line::Incomplete);
};

Worth a unit test for text("1\tone\tt\n2\ttwo\tt\\") and the single-column variant.

Comment on lines +961 to +980
fn parse_decimal(value: &str, precision: usize, scale: usize) -> Option<i128> {
let (sign, digits) = match value.strip_prefix('-') {
Some(digits) => (-1, digits),
None => (1, value.strip_prefix('+').unwrap_or(value)),
};

let mut parts = digits.split('.');
let integer = parts.next().unwrap_or("");
let fraction = parts.next().unwrap_or("");
if parts.next().is_some() || fraction.len() > scale {
return None;
}

let unscaled = format!("{}{:0<width$}", integer, fraction, width = scale);
if unscaled.len() > precision {
return None;
}

Some(sign * unscaled.parse::<i128>().ok()?)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parse_decimal rejects values PostgreSQL accepts.

Both checks are off relative to PG's numeric input:

  • unscaled.len() > precision counts leading zeros, so 007 into numeric(2,0) is rejected (PG stores 7), and 0.05 into numeric(2,2) becomes "005" → rejected (PG stores 0.05, since numeric(2,2) holds values in (-1, 1) with 2 decimals).
  • fraction.len() > scale errors, but PG rounds to the declared scale: 1.234 into numeric(4,2) is 1.23, not an error. With the ExactNumberInfo::None default of Decimal(38, 10) (router.rs:1048) that means bare NUMERIC refuses anything with more than 10 decimals, and NUMERIC(10) (scale 0) refuses 1.5.

Roughly: round/truncate the fraction to scale first, then strip leading zeros from unscaled before comparing against precision.

Comment on lines +289 to +291
fn column_names(columns: &[ast::Ident]) -> Vec<String> {
columns.iter().map(|column| column.value.clone()).collect()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Column names here keep the identifier exactly as written, while the COPY column list goes through normalize_ident (router.rs:972) and the schema stores folded names. So COPY t (A) FROM STDIN works, but COPY t FROM STDIN WITH (FORMAT csv, FORCE_NOT_NULL (A)) fails the schema.index_of validation in copy_from_plan with column "A" of relation "t" does not exist, and even if it passed, resolve_value compares against the folded field name and would never match.

column_names should apply the same folding (lowercase unquoted idents, keep quoted ones verbatim).

Comment on lines +447 to +448
// Columns which the COPY statement did not list stay NULL
let mut values: Vec<Option<String>> = vec![None; self.builders.len()];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOT NULL is accepted by create_empty_table_to_plan and stored in the Arrow field's nullable flag, but nothing enforces it here: unlisted columns are pre-filled with None, and an explicit \N for a non-nullable column is appended as a null too. test_copy_from_stdin_column_types declares s varchar(10) NOT NULL but always supplies a value, so the gap isn't covered.

Depending on whether this fork's RecordBatch::try_new validates nullability, the result is either a silently violated constraint or an opaque Unable to build COPY data: … internal error at finish(). Either way it'd be better to check at append time and raise PG's null value in column "s" violates not-null constraint (23502) — or, if enforcing is out of scope, reject NOT NULL in the DDL like the other unsupported column options.

Comment on lines +1078 to +1080
ast::DataType::Timestamp(_, _) | ast::DataType::Datetime(_) => {
DataType::Timestamp(TimeUnit::Nanosecond, None)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: the timezone flag of ast::DataType::Timestamp(_, tz) is discarded, so timestamptz silently becomes a naive Timestamp(Nanosecond, None), and parse_timestamp (copy.rs:949) has no format with an offset — a perfectly ordinary 2024-03-01 10:20:30+03 then fails with invalid input syntax for type timestamp. Either reject WITH TIME ZONE explicitly at DDL time or accept an offset and normalize to UTC.

@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.98305% with 234 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.26%. Comparing base (f7822ca) to head (dfb05cf).

Files with missing lines Patch % Lines
rust/cubesql/cubesql/src/sql/postgres/copy.rs 90.97% 79 Missing ⚠️
rust/cubesql/cubesql/src/compile/router.rs 71.24% 67 Missing ⚠️
rust/cubesql/cubesql/src/compile/copy.rs 87.60% 31 Missing ⚠️
rust/cubesql/pg-srv/src/protocol.rs 70.12% 23 Missing ⚠️
rust/cubesql/cubesql/src/compile/test/test_copy.rs 97.13% 14 Missing ⚠️
rust/cubesql/cubesql/src/compile/plan.rs 0.00% 10 Missing ⚠️
rust/cubesql/cubesql/src/sql/temp_tables.rs 94.56% 5 Missing ⚠️
rust/cubesql/cubesql/src/sql/postgres/shim.rs 96.34% 3 Missing ⚠️
rust/cubesql/cubesql/src/compile/test/mod.rs 0.00% 1 Missing ⚠️
rust/cubesql/cubesql/src/sql/postgres/extended.rs 90.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           master   #11538       +/-   ##
===========================================
+ Coverage   59.35%   84.26%   +24.91%     
===========================================
  Files         223      260       +37     
  Lines       17989    83621    +65632     
  Branches     3641        0     -3641     
===========================================
+ Hits        10677    70463    +59786     
- Misses       6793    13158     +6365     
+ Partials      519        0      -519     
Flag Coverage Δ
cube-backend ?
cubesql 84.26% <88.98%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

rust Pull requests that update Rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant