Skip to content

Commit d6b0971

Browse files
authored
Merge pull request #241 from hotdata-dev/feat/ingest-raw-sql
feat(ingest): add `hotdata ingest raw-sql` verb
2 parents cccd179 + 16c9561 commit d6b0971

3 files changed

Lines changed: 111 additions & 2 deletions

File tree

‎skills/hotdata/SKILL.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,10 +274,17 @@ hotdata ingest status <import-id> --wait # attach and poll to done/failed
274274

275275
hotdata ingest list-imports # the SQL behind each import + its database
276276
hotdata ingest trigger-import <import-id> # re-run: refresh the same DB from source
277+
278+
# raw-sql (SQL datasources only): run a source-native query VERBATIM in the
279+
# source dialect and ingest its result — joins, GROUP BY, window functions, CTEs,
280+
# engine-specific functions all run at the source. Single read-only statement.
281+
hotdata ingest raw-sql --source prod_pg --table rev_by_seg \
282+
"SELECT c_mktsegment, SUM(o_totalprice) AS rev FROM customer c JOIN orders o ON o.o_custkey=c.c_custkey GROUP BY 1"
277283
```
278284

279285
Agent tips:
280-
- Import SQL is a **restricted grammar**: `SELECT <cols|*> FROM <datasource>[.<table>] [WHERE …] [LIMIT n]` — no joins/GROUP BY. Run real analytics with `hotdata query` against the imported database afterward.
286+
- `new-import` SQL is a **restricted grammar**: `SELECT <cols|*> FROM <datasource>[.<table>] [WHERE …] [LIMIT n]` — no joins/GROUP BY. For those, either use `raw-sql` (pushes the full query down to a SQL source) or run `hotdata query` against the imported database afterward.
287+
- `raw-sql` is SQL-datasources-only and read-only (a single SELECT; writes/DDL are refused). Prefer a **read-only source credential**. Cast ambiguous/mixed-precision decimal columns explicitly in the query.
281288
- Prefer `-o json` + the `status` exit codes for scripting; poll one-shot `status` rather than holding `--wait` when doing other work in between.
282289
- `status` is a **closed set**: `pending` | `running` | `done` | `failed`. While running, the finer progress state (e.g. `extracting`, `loading`) appears in `stage` — informational only, never switch on it.
283290
- Tables print oldest→newest; `-o json` is newest-first (`[0]` = latest).

‎src/client/ingest.rs‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,19 @@ impl IngestClient {
268268
)
269269
}
270270

271+
/// Ingest the result of a source-native SQL query (SQL datasources only).
272+
/// The SQL runs verbatim in the source dialect; the server enforces a single
273+
/// read-only statement.
274+
pub fn create_raw_query(&self, req: &RawSqlIngest) -> Result<IngestAck, IngestError> {
275+
self.require_api_key()?;
276+
let body = serde_json::to_value(req).expect("RawSqlIngest serializes");
277+
self.send(
278+
self.authed(reqwest::Method::POST, "/queries/raw")
279+
.json(&body),
280+
Some(&body),
281+
)
282+
}
283+
271284
pub fn drain(&self) -> Result<serde_json::Value, IngestError> {
272285
self.send(self.authed(reqwest::Method::POST, "/jobs/drain"), None)
273286
}
@@ -429,6 +442,18 @@ pub struct QueryToIngest {
429442
pub database_id: Option<String>,
430443
}
431444

445+
/// `POST /ingest/queries/raw` body: source-native SQL run verbatim against a SQL
446+
/// datasource, its result loaded into `table`. The workspace rides the
447+
/// X-Workspace-Id header, so it is not in the body.
448+
#[derive(Debug, Serialize)]
449+
pub struct RawSqlIngest {
450+
pub sql: String,
451+
pub source: String,
452+
pub table: String,
453+
#[serde(skip_serializing_if = "Option::is_none")]
454+
pub limit: Option<u64>,
455+
}
456+
432457
/// 202 body from `POST /sources` and `POST /queries`. Common fields are typed;
433458
/// everything else (e.g. `resource`, `columns`, `where`, `limit` on queries) is
434459
/// captured for `--output json`.

‎src/commands/ingest.rs‎

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
//! `name`, `type`, `status`, `stage`, …), not the wire rows.
3838
3939
use crate::client::ingest::{
40-
ConnectorEntry, IngestAck, IngestClient, IngestRequest, JobStatus, QueryToIngest,
40+
ConnectorEntry, IngestAck, IngestClient, IngestRequest, JobStatus, QueryToIngest, RawSqlIngest,
4141
};
4242
use crate::commands::prompt::{
4343
ask_secret, ask_text, optional, optional_default, prompt_list, select_optional,
@@ -160,6 +160,34 @@ pub enum IngestCommands {
160160
poll: PollArgs,
161161
},
162162

163+
/// Ingest the result of a source-native SQL query (SQL datasources only)
164+
///
165+
/// Unlike `new-import` (SELECT <cols|*> FROM <ds>[.<table>] [WHERE] [LIMIT]),
166+
/// the SQL runs verbatim against the source engine in its OWN dialect —
167+
/// joins, aggregation, GROUP BY, CTEs, window functions, and engine-specific
168+
/// functions all execute at the source; only the result set transfers. It
169+
/// must be a single read-only statement; the result lands in `--table` in a
170+
/// fresh managed database.
171+
RawSql {
172+
/// A single read-only SQL statement, in the SOURCE engine's dialect
173+
sql: String,
174+
175+
/// SQL datasource to run against: a name or a datasource id
176+
#[arg(long)]
177+
source: String,
178+
179+
/// Result table name in the new managed database
180+
#[arg(long)]
181+
table: String,
182+
183+
/// Cap the result at N rows
184+
#[arg(long)]
185+
limit: Option<u64>,
186+
187+
#[command(flatten)]
188+
poll: PollArgs,
189+
},
190+
163191
/// List your imports: the SQL behind each and the database it landed in
164192
ListImports,
165193

@@ -252,6 +280,13 @@ pub fn dispatch(workspace_id: &str, output: &str, command: IngestCommands) {
252280
database_id,
253281
poll,
254282
} => new_import(workspace_id, output, sql, all, source, database_id, &poll),
283+
IngestCommands::RawSql {
284+
sql,
285+
source,
286+
table,
287+
limit,
288+
poll,
289+
} => raw_sql(workspace_id, output, sql, source, table, limit, &poll),
255290
IngestCommands::ListImports => list_imports(workspace_id, output),
256291
IngestCommands::TriggerImport { id, poll } => {
257292
trigger_import(workspace_id, output, &id, &poll)
@@ -940,6 +975,48 @@ fn looks_like_ingest_id(s: &str) -> bool {
940975
s.len() == 32 && s.chars().all(|c| c.is_ascii_hexdigit())
941976
}
942977

978+
// --- raw-sql: source-native SQL front-door --------------------------------
979+
980+
fn raw_sql(
981+
workspace_id: &str,
982+
output: &str,
983+
sql: String,
984+
source: String,
985+
table: String,
986+
limit: Option<u64>,
987+
poll: &PollArgs,
988+
) {
989+
let client = IngestClient::new(workspace_id);
990+
let req = RawSqlIngest {
991+
sql,
992+
source,
993+
table,
994+
limit,
995+
};
996+
let spinner = util::spinner("submitting raw-sql import…");
997+
let ack = client.create_raw_query(&req).unwrap_or_else(|e| {
998+
spinner.finish_and_clear();
999+
e.exit()
1000+
});
1001+
spinner.finish_and_clear();
1002+
if poll.wait {
1003+
let st = poll_ingest(
1004+
&client,
1005+
output,
1006+
&ack.ingest_id,
1007+
poll.wait_timeout,
1008+
"importing",
1009+
true,
1010+
);
1011+
render_done(&st, output);
1012+
return;
1013+
}
1014+
// Non-blocking default: kick the drain so the enqueued job actually runs,
1015+
// then hand the terminal back (same as new-import).
1016+
let _ = client.drain();
1017+
render_ack(&ack, "import id:", output);
1018+
}
1019+
9431020
/// The SQL a `new-import` runs: explicit SQL wins; `--all` becomes a full
9441021
/// `SELECT *` against the datasource's name (`pinned_name` when --source was
9451022
/// a datasource id). Errors are messages for `fail`.

0 commit comments

Comments
 (0)