Skip to content

Commit 2fe1a45

Browse files
committed
feat(duckdb): INSTALL extension
1 parent 79c5c30 commit 2fe1a45

File tree

6 files changed

+203
-0
lines changed

6 files changed

+203
-0
lines changed

sqlglot/dialects/duckdb.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,8 @@ class Tokenizer(tokens.Tokenizer):
338338
"DATETIME": TokenType.TIMESTAMPNTZ,
339339
"DETACH": TokenType.DETACH,
340340
"EXCLUDE": TokenType.EXCEPT,
341+
"FORCE": TokenType.FORCE,
342+
"INSTALL": TokenType.INSTALL,
341343
"LOGICAL": TokenType.BOOLEAN,
342344
"ONLY": TokenType.ONLY,
343345
"PIVOT_WIDER": TokenType.PIVOT,
@@ -494,6 +496,8 @@ class Parser(parser.Parser):
494496
**parser.Parser.STATEMENT_PARSERS,
495497
TokenType.ATTACH: lambda self: self._parse_attach_detach(),
496498
TokenType.DETACH: lambda self: self._parse_attach_detach(is_attach=False),
499+
TokenType.FORCE: lambda self: self._parse_force(),
500+
TokenType.INSTALL: lambda self: self._parse_install(),
497501
TokenType.SHOW: lambda self: self._parse_show(),
498502
}
499503

@@ -631,6 +635,26 @@ def _parse_attach_option() -> exp.AttachOption:
631635
def _parse_show_duckdb(self, this: str) -> exp.Show:
632636
return self.expression(exp.Show, this=this)
633637

638+
def _parse_force(self) -> exp.Install:
639+
# FORCE can only be followed by INSTALL in DuckDB
640+
if not self._match(TokenType.INSTALL):
641+
self.raise_error("Expected INSTALL after FORCE")
642+
643+
return self._parse_install(force=True)
644+
645+
def _parse_install(self, force: bool = False) -> exp.Install:
646+
# Parse extension name (can be a string path or identifier)
647+
this = self._parse_string() or self._parse_id_var()
648+
649+
# Parse optional FROM clause
650+
from_ = None
651+
if self._match(TokenType.FROM):
652+
from_ = self._parse_string() or self._parse_id_var()
653+
if not from_:
654+
self.raise_error("Expected repository name after FROM")
655+
656+
return self.expression(exp.Install, this=this, from_=from_, force=force)
657+
634658
def _parse_primary(self) -> t.Optional[exp.Expression]:
635659
if self._match_pair(TokenType.HASH, TokenType.NUMBER):
636660
return exp.PositionalColumn(this=exp.Literal.number(self._prev.text))
@@ -944,6 +968,13 @@ def lambda_sql(
944968
def show_sql(self, expression: exp.Show) -> str:
945969
return f"SHOW {expression.name}"
946970

971+
def install_sql(self, expression: exp.Install) -> str:
972+
force = "FORCE " if expression.args.get("force") else ""
973+
this = self.sql(expression, "this")
974+
from_ = expression.args.get("from_")
975+
from_clause = f" FROM {self.sql(from_)}" if from_ else ""
976+
return f"{force}INSTALL {this}{from_clause}"
977+
947978
def fromiso8601timestamp_sql(self, expression: exp.FromISO8601Timestamp) -> str:
948979
return self.sql(exp.cast(expression.this, exp.DataType.Type.TIMESTAMPTZ))
949980

sqlglot/expressions.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1571,6 +1571,11 @@ class Detach(Expression):
15711571
arg_types = {"this": True, "exists": False}
15721572

15731573

1574+
# https://duckdb.org/docs/sql/statements/load_and_install.html
1575+
class Install(Expression):
1576+
arg_types = {"this": True, "from_": False, "force": False}
1577+
1578+
15741579
# https://duckdb.org/docs/guides/meta/summarize.html
15751580
class Summarize(Expression):
15761581
arg_types = {"this": True, "table": False}

sqlglot/tokens.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,7 @@ class TokenType(AutoName):
306306
INDEX = auto()
307307
INNER = auto()
308308
INSERT = auto()
309+
INSTALL = auto()
309310
INTERSECT = auto()
310311
INTERVAL = auto()
311312
INTO = auto()

tests/dialects/test_duckdb.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1733,6 +1733,87 @@ def test_reset(self):
17331733
self.validate_identity("RESET LOCAL threads", check_command_warning=True)
17341734
self.validate_identity("RESET SESSION default_collation", check_command_warning=True)
17351735

1736+
def test_install(self):
1737+
# Test basic INSTALL command
1738+
self.validate_identity("INSTALL httpfs")
1739+
self.validate_identity("INSTALL spatial")
1740+
1741+
# Test INSTALL with string literals (file paths)
1742+
self.validate_identity("INSTALL 'path/to/extension.duckdb_extension'")
1743+
self.validate_identity("INSTALL '/absolute/path/to/extension.duckdb_extension'")
1744+
1745+
# Test INSTALL with FROM clause
1746+
self.validate_identity("INSTALL httpfs FROM community")
1747+
self.validate_identity("INSTALL spatial FROM community")
1748+
self.validate_identity("INSTALL h3 FROM community")
1749+
self.validate_identity("INSTALL spatial FROM core_nightly")
1750+
1751+
# Test INSTALL with FROM clause using string literals (URLs)
1752+
self.validate_identity("INSTALL spatial FROM 'http://nightly-extensions.duckdb.org'")
1753+
self.validate_identity("INSTALL httpfs FROM 'https://extensions.duckdb.org'")
1754+
1755+
# Test FORCE INSTALL command
1756+
self.validate_identity("FORCE INSTALL httpfs")
1757+
self.validate_identity("FORCE INSTALL spatial")
1758+
1759+
# Test FORCE INSTALL with FROM clause
1760+
self.validate_identity("FORCE INSTALL httpfs FROM community")
1761+
self.validate_identity("FORCE INSTALL spatial FROM community")
1762+
self.validate_identity("FORCE INSTALL spatial FROM 'http://nightly-extensions.duckdb.org'")
1763+
1764+
# Test parsing and generation of Install expression
1765+
install_expr = parse_one("INSTALL httpfs", dialect="duckdb")
1766+
self.assertIsInstance(install_expr, exp.Install)
1767+
self.assertEqual(install_expr.args["this"].name, "httpfs")
1768+
self.assertIsNone(install_expr.args.get("from_"))
1769+
self.assertFalse(install_expr.args.get("force", False))
1770+
1771+
# Test parsing with FROM clause
1772+
install_with_from = parse_one("INSTALL spatial FROM community", dialect="duckdb")
1773+
self.assertIsInstance(install_with_from, exp.Install)
1774+
self.assertEqual(install_with_from.args["this"].name, "spatial")
1775+
self.assertEqual(install_with_from.args["from_"].name, "community")
1776+
self.assertFalse(install_with_from.args.get("force", False))
1777+
1778+
# Test parsing FORCE INSTALL
1779+
force_install = parse_one("FORCE INSTALL httpfs", dialect="duckdb")
1780+
self.assertIsInstance(force_install, exp.Install)
1781+
self.assertEqual(force_install.args["this"].name, "httpfs")
1782+
self.assertIsNone(force_install.args.get("from_"))
1783+
self.assertTrue(force_install.args.get("force", False))
1784+
1785+
# Test FORCE INSTALL with FROM
1786+
force_install_from = parse_one("FORCE INSTALL spatial FROM community", dialect="duckdb")
1787+
self.assertIsInstance(force_install_from, exp.Install)
1788+
self.assertEqual(force_install_from.args["this"].name, "spatial")
1789+
self.assertEqual(force_install_from.args["from_"].name, "community")
1790+
self.assertTrue(force_install_from.args.get("force", False))
1791+
1792+
# Test string path parsing
1793+
install_path = parse_one("INSTALL 'path/to/ext.duckdb_extension'", dialect="duckdb")
1794+
self.assertIsInstance(install_path, exp.Install)
1795+
self.assertEqual(install_path.args["this"].this, "path/to/ext.duckdb_extension")
1796+
1797+
# Test cross-dialect compatibility (should not parse as Install in other dialects)
1798+
# In other dialects, INSTALL should not be recognized as a special command
1799+
try:
1800+
non_duckdb_result = parse_one("INSTALL httpfs", dialect="postgres")
1801+
# Should parse as something else, not Install
1802+
self.assertNotIsInstance(non_duckdb_result, exp.Install)
1803+
except Exception:
1804+
# Or fail to parse entirely, which is also acceptable
1805+
pass
1806+
1807+
# Test error cases
1808+
with self.assertRaises(ParseError):
1809+
parse_one("INSTALL", dialect="duckdb") # Missing extension name
1810+
1811+
with self.assertRaises(ParseError):
1812+
parse_one("FORCE", dialect="duckdb") # FORCE without INSTALL
1813+
1814+
with self.assertRaises(ParseError):
1815+
parse_one("INSTALL httpfs FROM", dialect="duckdb") # FROM without repository
1816+
17361817
def test_map_struct(self):
17371818
self.validate_identity("MAP {1: 'a', 2: 'b'}")
17381819
self.validate_identity("MAP {'1': 'a', '2': 'b'}")

tests/test_expressions.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1262,3 +1262,38 @@ def test_convert_datetime_time(self):
12621262

12631263
self.assertIsInstance(result, exp.TsOrDsToTime)
12641264
self.assertEqual(result.sql(), "CAST('12:00:00' AS TIME)")
1265+
1266+
def test_install_expression(self):
1267+
# Test Install expression construction
1268+
install = exp.Install(this=exp.Identifier(this="httpfs"))
1269+
self.assertEqual(install.args["this"].name, "httpfs")
1270+
self.assertIsNone(install.args.get("from_"))
1271+
self.assertFalse(install.args.get("force", False))
1272+
1273+
# Test Install with FROM clause
1274+
install_with_from = exp.Install(
1275+
this=exp.Identifier(this="spatial"), from_=exp.Identifier(this="community")
1276+
)
1277+
self.assertEqual(install_with_from.args["this"].name, "spatial")
1278+
self.assertEqual(install_with_from.args["from_"].name, "community")
1279+
self.assertFalse(install_with_from.args.get("force", False))
1280+
1281+
# Test FORCE Install
1282+
force_install = exp.Install(this=exp.Identifier(this="httpfs"), force=True)
1283+
self.assertEqual(force_install.args["this"].name, "httpfs")
1284+
self.assertIsNone(force_install.args.get("from_"))
1285+
self.assertTrue(force_install.args.get("force", False))
1286+
1287+
# Test Install with string literal
1288+
install_path = exp.Install(this=exp.Literal.string("path/to/ext.duckdb_extension"))
1289+
self.assertEqual(install_path.args["this"].this, "path/to/ext.duckdb_extension")
1290+
self.assertTrue(install_path.args["this"].is_string)
1291+
1292+
# Test Install expression key
1293+
install = exp.Install(this=exp.Identifier(this="httpfs"))
1294+
self.assertEqual(install.key, "install")
1295+
1296+
# Test Install arg_types validation
1297+
self.assertEqual(exp.Install.arg_types["this"], True) # Required
1298+
self.assertEqual(exp.Install.arg_types["from_"], False) # Optional
1299+
self.assertEqual(exp.Install.arg_types["force"], False) # Optional

tests/test_tokens.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,53 @@ def test_token_repr(self):
208208
repr(Tokenizer().tokenize("foo")),
209209
"[<Token token_type: TokenType.VAR, text: foo, line: 1, col: 3, start: 0, end: 2, comments: []>]",
210210
)
211+
212+
def test_duckdb_install_tokens(self):
213+
from sqlglot.dialects import DuckDB
214+
215+
tokenizer = DuckDB.Tokenizer()
216+
217+
# Test INSTALL token
218+
tokens = tokenizer.tokenize("INSTALL httpfs")
219+
self.assertEqual(tokens[0].token_type, TokenType.INSTALL)
220+
self.assertEqual(tokens[0].text, "INSTALL")
221+
self.assertEqual(tokens[1].token_type, TokenType.VAR)
222+
self.assertEqual(tokens[1].text, "httpfs")
223+
224+
# Test FORCE token
225+
tokens = tokenizer.tokenize("FORCE INSTALL httpfs")
226+
self.assertEqual(tokens[0].token_type, TokenType.FORCE)
227+
self.assertEqual(tokens[0].text, "FORCE")
228+
self.assertEqual(tokens[1].token_type, TokenType.INSTALL)
229+
self.assertEqual(tokens[1].text, "INSTALL")
230+
self.assertEqual(tokens[2].token_type, TokenType.VAR)
231+
self.assertEqual(tokens[2].text, "httpfs")
232+
233+
# Test INSTALL with FROM
234+
tokens = tokenizer.tokenize("INSTALL spatial FROM community")
235+
self.assertEqual(tokens[0].token_type, TokenType.INSTALL)
236+
self.assertEqual(tokens[0].text, "INSTALL")
237+
self.assertEqual(tokens[1].token_type, TokenType.VAR)
238+
self.assertEqual(tokens[1].text, "spatial")
239+
self.assertEqual(tokens[2].token_type, TokenType.FROM)
240+
self.assertEqual(tokens[2].text, "FROM")
241+
self.assertEqual(tokens[3].token_type, TokenType.VAR)
242+
self.assertEqual(tokens[3].text, "community")
243+
244+
# Test INSTALL with string literal
245+
tokens = tokenizer.tokenize("INSTALL 'path/to/ext.duckdb_extension'")
246+
self.assertEqual(tokens[0].token_type, TokenType.INSTALL)
247+
self.assertEqual(tokens[0].text, "INSTALL")
248+
self.assertEqual(tokens[1].token_type, TokenType.STRING)
249+
self.assertEqual(tokens[1].text, "path/to/ext.duckdb_extension")
250+
251+
# Test case insensitivity
252+
tokens = tokenizer.tokenize("install httpfs")
253+
self.assertEqual(tokens[0].token_type, TokenType.INSTALL)
254+
self.assertEqual(tokens[0].text, "install")
255+
256+
tokens = tokenizer.tokenize("force install httpfs")
257+
self.assertEqual(tokens[0].token_type, TokenType.FORCE)
258+
self.assertEqual(tokens[0].text, "force")
259+
self.assertEqual(tokens[1].token_type, TokenType.INSTALL)
260+
self.assertEqual(tokens[1].text, "install")

0 commit comments

Comments
 (0)