Skip to content

Commit 1267083

Browse files
Adds MotherDuck support to DuckDB native fdw. (#511)
* Adds MotherDuck support to duckdb native fdw. MotherDuck is remote DuckDB instance that gets ATTACHed to the local one so we're able to honor the LIMIT TO / EXCEPT table list arguments to `IMPORT FOREIGN SCHEMA` by pushing them down to the query agains the remote information_schema. MotherDuck doesn't use a DuckDB secret block, instead it relies on env vars (not relevant here) or configuration settings. Despite what the docs say, passing those settings as url params to the first argument to ATTACH doesn't seem to work any longer. * Fixed code formatting issue. * Fix code formatting issues and a bug the crept in while cleaning up the code. * change duckdb_fdw version number --------- Co-authored-by: Bo Lu <[email protected]>
1 parent 7ba487f commit 1267083

File tree

4 files changed

+123
-11
lines changed

4 files changed

+123
-11
lines changed

wrappers/src/fdw/duckdb_fdw/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@ This is a foreign data wrapper for [DuckDB](https://duckdb.org/). It is develope
1010

1111
| Version | Date | Notes |
1212
| ------- | ---------- | ---------------------------------------------------- |
13+
| 0.1.2 | 2025-10-16 | Add MotherDuck support |
1314
| 0.1.1 | 2025-08-15 | Replace execute_batch() with execute() |
1415
| 0.1.0 | 2024-10-31 | Initial version |
15-

wrappers/src/fdw/duckdb_fdw/duckdb_fdw.rs

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use supabase_wrappers::prelude::*;
99
use super::{mapper, server_type::ServerType, DuckdbFdwError, DuckdbFdwResult};
1010

1111
#[wrappers_fdw(
12-
version = "0.1.1",
12+
version = "0.1.2",
1313
author = "Supabase",
1414
website = "https://github.com/supabase/wrappers/tree/main/wrappers/src/fdw/duckdb_fdw",
1515
error_type = "DuckdbFdwError"
@@ -28,13 +28,8 @@ impl DuckdbFdw {
2828

2929
fn init_duckdb(&self) -> DuckdbFdwResult<()> {
3030
let sql_batch = String::default()
31-
+ if self.svr_type.is_iceberg() { "install iceberg;load iceberg;" } else { "" }
32-
// security tips: https://duckdb.org/docs/stable/operations_manual/securing_duckdb/overview
33-
+ "
34-
set disabled_filesystems = 'LocalFileSystem';
35-
set allow_community_extensions = false;
36-
set lock_configuration = true;
37-
"
31+
+ self.svr_type.get_duckdb_extension_sql()
32+
+ &self.svr_type.get_settings_sql(&self.svr_opts)
3833
+ &self.svr_type.get_create_secret_sql(&self.svr_opts)
3934
+ &self.svr_type.get_attach_sql(&self.svr_opts)?;
4035

@@ -291,6 +286,47 @@ impl ForeignDataWrapper<DuckdbFdwError> for DuckdbFdw {
291286
.iter()
292287
.map(|t| (t.clone(), t.replace(".", "_")))
293288
.collect()
289+
} else if self.svr_type.is_sql_like() {
290+
let db_name = self.svr_type.as_str();
291+
let schema = import_stmt.remote_schema;
292+
293+
let table_list = import_stmt
294+
.table_list
295+
.iter()
296+
.map(|name| format!("'{}'", name.replace("'", "''")))
297+
.collect::<Vec<_>>()
298+
.join(",");
299+
let table_filter = match import_stmt.list_type {
300+
ImportSchemaType::FdwImportSchemaAll => "".to_string(),
301+
ImportSchemaType::FdwImportSchemaLimitTo => {
302+
format!("and table_name in ({table_list})")
303+
}
304+
ImportSchemaType::FdwImportSchemaExcept => {
305+
format!("and table_name not in ({table_list})")
306+
}
307+
};
308+
let query = format!(
309+
"
310+
select table_name
311+
from information_schema.tables
312+
where table_catalog = ?
313+
and table_schema = ?
314+
{table_filter}
315+
order by table_name
316+
"
317+
);
318+
let mut stmt = self.conn.prepare(&query)?;
319+
let tables = stmt
320+
.query_map([db_name, &schema], |row| row.get::<_, String>(0))?
321+
.collect::<Result<Vec<_>, _>>()?;
322+
323+
// NOTE: We don't do any munging of the postgres table names because
324+
// the pg code will ignore CREATE FOREIGN TABLE statements that don't target
325+
// the table names specified in the IMPORT FOREIGN SCHEMA statement.
326+
tables
327+
.iter()
328+
.map(|t| (format!("{db_name}.{schema}.{t}"), t.clone()))
329+
.collect()
294330
} else {
295331
let tables = require_option_or("tables", &import_stmt.options, "")
296332
.split(",")

wrappers/src/fdw/duckdb_fdw/server_type.rs

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ pub(super) enum ServerType {
1919
R2Catalog,
2020
Polaris,
2121
Lakekeeper,
22+
23+
// SQL-like Remotes
24+
MotherDuck,
2225
}
2326

2427
impl ServerType {
@@ -32,6 +35,7 @@ impl ServerType {
3235
"r2_catalog" => Self::R2Catalog,
3336
"polaris" => Self::Polaris,
3437
"lakekeeper" => Self::Lakekeeper,
38+
"md" => Self::MotherDuck,
3539
_ => return Err(DuckdbFdwError::InvalidServerType(svr_type.to_owned())),
3640
};
3741
Ok(ret)
@@ -46,6 +50,7 @@ impl ServerType {
4650
Self::R2Catalog => "r2_catalog",
4751
Self::Polaris => "polaris",
4852
Self::Lakekeeper => "lakekeeper",
53+
Self::MotherDuck => "md",
4954
}
5055
}
5156

@@ -56,6 +61,20 @@ impl ServerType {
5661
)
5762
}
5863

64+
pub(super) fn is_sql_like(&self) -> bool {
65+
matches!(self, Self::MotherDuck)
66+
}
67+
68+
pub(super) fn get_duckdb_extension_sql(&self) -> &'static str {
69+
match self {
70+
Self::Iceberg | Self::S3Tables | Self::R2Catalog | Self::Polaris | Self::Lakekeeper => {
71+
"install iceberg;load iceberg;"
72+
}
73+
Self::MotherDuck => "install md;load md;",
74+
_ => "",
75+
}
76+
}
77+
5978
fn allowed_secret_params(&self) -> Vec<&'static str> {
6079
match self {
6180
// ref: https://duckdb.org/docs/stable/core_extensions/httpfs/s3api.html#overview-of-s3-secret-parameters
@@ -81,6 +100,7 @@ impl ServerType {
81100
"oauth2_scope",
82101
"oauth2_server_uri",
83102
],
103+
Self::MotherDuck => vec![],
84104
}
85105
}
86106

@@ -123,6 +143,8 @@ impl ServerType {
123143
Self::R2Catalog | Self::Polaris | Self::Lakekeeper => {
124144
vec![("iceberg", self.allowed_secret_params())]
125145
}
146+
147+
_ => vec![],
126148
};
127149

128150
let mut ret = String::default();
@@ -134,6 +156,44 @@ impl ServerType {
134156
ret
135157
}
136158

159+
pub(super) fn get_settings_sql(&self, svr_opts: &ServerOptions) -> String {
160+
let settings: Vec<(&str, String)> = match self {
161+
Self::MotherDuck => {
162+
let token = if svr_opts.contains_key("vault_motherduck_token") {
163+
get_vault_secret(svr_opts.get("vault_motherduck_token").unwrap()).ok_or_else(
164+
|| OptionsError::OptionNameNotFound("vault_motherduck_token".to_string()),
165+
)
166+
} else {
167+
require_option("motherduck_token", svr_opts).map(|s| s.to_string())
168+
}
169+
.expect("motherduck_token is required");
170+
171+
let sanitized_token = format!("'{}'", token.replace("'", "''"));
172+
vec![
173+
("motherduck_token", sanitized_token),
174+
("motherduck_attach_mode", "'single'".to_string()),
175+
("allow_community_extensions", "false".to_string()),
176+
// Has the same effect as below, disables the local filesystem and locks the config.
177+
("motherduck_saas_mode", "true".to_string()),
178+
]
179+
}
180+
_ => {
181+
// security tips: https://duckdb.org/docs/stable/operations_manual/securing_duckdb/overview
182+
vec![
183+
("disabled_filesystems", "'LocalFileSystem'".to_string()),
184+
("allow_community_extensions", "false".to_string()),
185+
("lock_configuration", "true".to_string()),
186+
]
187+
}
188+
};
189+
let mut ret = String::default();
190+
for (key, value) in settings {
191+
ret.push_str(&format!("set {key}={value};"));
192+
}
193+
194+
ret
195+
}
196+
137197
pub(super) fn get_attach_sql(&self, svr_opts: &ServerOptions) -> DuckdbFdwResult<String> {
138198
let ret = match self {
139199
Self::S3Tables => {
@@ -143,7 +203,7 @@ impl ServerType {
143203
"
144204
attach '{arn}' as {db_name} (
145205
type iceberg,
146-
endpoint_type s3_tables
206+
endpoint_type s3_tables
147207
);"
148208
)
149209
}
@@ -160,6 +220,11 @@ impl ServerType {
160220
);"
161221
)
162222
}
223+
Self::MotherDuck => {
224+
let database = require_option("database", svr_opts)?.replace("'", "''");
225+
let db_name = self.as_str();
226+
format!("attach 'md:{database}' as {db_name};")
227+
}
163228
_ => String::default(),
164229
};
165230
Ok(ret)

wrappers/src/fdw/duckdb_fdw/tests.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,18 @@ mod tests {
5050
&[],
5151
)
5252
.unwrap();
53+
c.update(
54+
r#"CREATE SERVER duckdb_server_motherduck
55+
FOREIGN DATA WRAPPER duckdb_wrapper
56+
OPTIONS (
57+
type 'md',
58+
database 'my_db',
59+
motherduck_token 'my_token'
60+
)"#,
61+
None,
62+
&[],
63+
)
64+
.unwrap();
5365
c.update(r#"CREATE SCHEMA IF NOT EXISTS duckdb"#, None, &[])
5466
.unwrap();
5567
c.update(
@@ -71,7 +83,6 @@ mod tests {
7183
&[],
7284
)
7385
.unwrap();
74-
7586
let results = c
7687
.select(
7788
"SELECT * FROM duckdb.s3_0_test_data order by name",

0 commit comments

Comments
 (0)