sverify-dbt 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ """Check a dbt project's tests and model contracts with sverify, before the
2
+ models run.
3
+
4
+ import sverify_dbt
5
+
6
+ result = sverify_dbt.check("path/to/project") # after `dbt compile`
7
+ print(result)
8
+ result.write_selectors("path/to/project/selectors.yml")
9
+
10
+ Each model's compiled SQL is proved with `sverify.verify` against the tests
11
+ on the models and sources it reads. A test proved there holds on every run
12
+ whose inputs pass their tests, so `dbt build` may skip it
13
+ (`dbt build --exclude selector:sverify_proved`).
14
+ """
15
+
16
+ from sverify_dbt.check import check
17
+ from sverify_dbt.manifest import Project, ProjectError, load
18
+ from sverify_dbt.result import CheckResult, ContractIssue, ModelResult, Needed, TestResult
19
+
20
+ __all__ = [
21
+ "CheckResult",
22
+ "ContractIssue",
23
+ "ModelResult",
24
+ "Needed",
25
+ "Project",
26
+ "ProjectError",
27
+ "TestResult",
28
+ "check",
29
+ "load",
30
+ ]
sverify_dbt/check.py ADDED
@@ -0,0 +1,568 @@
1
+ """Check a compiled dbt project with sverify: each model's tests and
2
+ contract, proved from the tests on what it reads.
3
+
4
+ Each model is checked on its own, as dbt builds it: its compiled SQL is
5
+ `sv.verify`'s transformation over its inputs (its `ref`s and `source`s), the
6
+ tests on those inputs are `requires`, and its own tests and contract
7
+ constraints are `ensures`. Under `dbt build` a failing test with severity
8
+ error skips every model downstream, so a model's inputs meet their tests
9
+ whenever it runs: they are its preconditions, proved or not.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import warnings
16
+ from collections.abc import Iterable
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import duckdb
21
+ import sqlglot as sg
22
+ import sverify as sv
23
+
24
+ from sverify_dbt import mapping
25
+ from sverify_dbt.manifest import Project, Relation, Test, load
26
+ from sverify_dbt.result import (
27
+ CheckResult,
28
+ ContractIssue,
29
+ ModelResult,
30
+ Needed,
31
+ TestResult,
32
+ )
33
+
34
+
35
+ def check(
36
+ project: str | Path | Project,
37
+ *,
38
+ target_path: str | Path = "target",
39
+ select: Iterable[str] | None = None,
40
+ counterexamples: int = 200,
41
+ ) -> CheckResult:
42
+ """Check the tests and contracts of a dbt project after `dbt compile`.
43
+
44
+ Args:
45
+ project: The dbt project's directory, or a `Project` already loaded.
46
+ target_path: The project's target directory, relative to it.
47
+ select: Model names to report on (all by default). Their upstream
48
+ models are checked too, since their tests are the inputs'.
49
+ counterexamples: How many small inputs sverify tries for each
50
+ unproved test; 0 skips the search.
51
+
52
+ Returns:
53
+ A `CheckResult`: per model, the tests proved, not proved and not
54
+ checked, the contract, and the source tests it needs.
55
+
56
+ Raises:
57
+ sverify_dbt.ProjectError: No manifest, or not a DuckDB project.
58
+ """
59
+ proj = project if isinstance(project, Project) else load(project, target_path)
60
+ return _Checker(proj, counterexamples).run(set(select) if select else None)
61
+
62
+
63
+ class _Checker:
64
+ def __init__(self, project: Project, counterexamples: int):
65
+ self.p = project
66
+ self.counterexamples = counterexamples
67
+ self.names = _input_names(project.relations.values())
68
+ self.con = duckdb.connect()
69
+ self.results: dict[str, ModelResult] = {}
70
+ self.proved: set[str] = set()
71
+ """Unique ids of the tests proved so far, upstream first."""
72
+ self.created: dict[str, str | None] = {}
73
+ """Per relation, None once it is a table or view of the scratch
74
+ database, else why not."""
75
+
76
+ def run(self, select: set[str] | None) -> CheckResult:
77
+ models = self.p.models
78
+ if select:
79
+ wanted = {m.unique_id for m in models if m.name in select}
80
+ unknown = select - {m.name for m in models}
81
+ if unknown:
82
+ raise ValueError(f"no model named {', '.join(sorted(unknown))}")
83
+ wanted = self._upstream(wanted)
84
+ models = [m for m in models if m.unique_id in wanted]
85
+ for m in models:
86
+ self.results[m.unique_id] = self._model(m)
87
+ shown = [r for r in self.results.values() if not select or r.name in select]
88
+ return CheckResult(
89
+ project=self.p.name,
90
+ root=str(self.p.root),
91
+ models=shown,
92
+ tests={t.unique_id: t for t in self.p.tests.values()},
93
+ unit_tests=self.p.unit_tests,
94
+ relations=self.p.relations,
95
+ )
96
+
97
+ def _upstream(self, uids: set[str]) -> set[str]:
98
+ out = set(uids)
99
+ todo = list(uids)
100
+ while todo:
101
+ for d in self.p.relations[todo.pop()].depends_on:
102
+ if d not in out and d in self.p.relations:
103
+ out.add(d)
104
+ todo.append(d)
105
+ return out
106
+
107
+ # ---- inputs -------------------------------------------------------------
108
+
109
+ def _ensure_relation(self, r: Relation) -> str | None:
110
+ """Make `r` a table (sources, seeds) or view (models) of the scratch
111
+ DuckDB database, with the project's column types; None when done,
112
+ else why it cannot be."""
113
+ if r.unique_id in self.created:
114
+ return self.created[r.unique_id]
115
+ why: str | None = None
116
+ name = self.names[r.unique_id]
117
+ if r.is_model:
118
+ res = self.results.get(r.unique_id)
119
+ if res is None or res.sql is None:
120
+ why = f"{r.ref} could not be read" + (f" ({res.refused})" if res else "")
121
+ else:
122
+ try:
123
+ self.con.execute(f"CREATE VIEW {_q(name)} AS {res.sql}")
124
+ except duckdb.Error as e:
125
+ why = f"DuckDB cannot plan {r.ref}: {_first_line(e)}"
126
+ else:
127
+ types = r.declared_types()
128
+ if types is None:
129
+ why = (
130
+ f"{r.ref} has no column types: give each of its columns a data_type in "
131
+ "its YAML, or run `dbt docs generate` so that target/catalog.json has them"
132
+ )
133
+ else:
134
+ cols = ", ".join(f"{_q(c)} {t}" for c, t in types.items())
135
+ try:
136
+ self.con.execute(f"CREATE TABLE {_q(name)} ({cols})")
137
+ except duckdb.Error as e:
138
+ why = f"DuckDB does not know the column types of {r.ref}: {_first_line(e)}"
139
+ self.created[r.unique_id] = why
140
+ return why
141
+
142
+ def _not_null(self, r: Relation) -> set[str]:
143
+ """The columns of `r` its tests or contract keep from being NULL."""
144
+ out = set()
145
+ for t in self.p.tests_on(r.unique_id):
146
+ if (
147
+ self._holds(t)
148
+ and t.kind
149
+ in (
150
+ "not_null",
151
+ "dbt_expectations.expect_column_values_to_not_be_null",
152
+ )
153
+ and t.column
154
+ and not t.where
155
+ and not t.kwargs.get("row_condition")
156
+ ):
157
+ out.add(t.column)
158
+ if r.contract:
159
+ for c in r.columns.values():
160
+ for k in c.constraints:
161
+ if k.get("type") in ("not_null", "primary_key"):
162
+ out.add(c.name)
163
+ for k in r.constraints:
164
+ if k.get("type") in ("not_null", "primary_key"):
165
+ out.update(k.get("columns") or [])
166
+ return out
167
+
168
+ def _holds(self, t: Test) -> bool:
169
+ """The test holds when the model downstream runs: `dbt build` stops
170
+ on its failure, or it is proved."""
171
+ return t.blocks or t.unique_id in self.proved
172
+
173
+ def _requires(self, m: Relation, inputs: list[Relation]) -> tuple[dict, list[str]]:
174
+ """The preconditions per input table, and what was left out."""
175
+ out: dict[str, list] = {}
176
+ skipped: list[str] = []
177
+
178
+ def table_of(uid: str) -> str | None:
179
+ return self.names[uid] if uid in m.depends_on else None
180
+
181
+ for r in inputs:
182
+ name = self.names[r.unique_id]
183
+ conds: list = []
184
+ nn = self._not_null(r)
185
+ for t in self.p.tests_on(r.unique_id):
186
+ if not self._holds(t):
187
+ sev = t.severity.lower()
188
+ skipped.append(f"{r.ref}: {t.short} (severity {sev}, not proved)")
189
+ continue
190
+ got = mapping.test_conditions(t, side="requires", table_of=table_of, not_null=nn)
191
+ if got.conditions:
192
+ conds += got.conditions
193
+ elif got.reason and t.kind != "relationships":
194
+ skipped.append(f"{r.ref}: {t.short}: {got.reason}")
195
+ if r.is_model and r.contract:
196
+ res = self.results.get(r.unique_id)
197
+ for c in res.constraints if res else []:
198
+ if c.status == "proved":
199
+ conds += c.conditions
200
+ out[name] = conds
201
+ return out, skipped
202
+
203
+ # ---- one model ----------------------------------------------------------
204
+
205
+ def _model(self, m: Relation) -> ModelResult:
206
+ res = ModelResult(name=m.name, unique_id=m.unique_id, path=m.path, ref=m.ref)
207
+ if m.language != "sql":
208
+ return res.refuse(f"a {m.language} model: sverify reads SQL models only")
209
+ if not m.compiled_code:
210
+ return res.refuse(
211
+ "no compiled SQL in the manifest: run `dbt compile` (or `dbt build`) first; "
212
+ "`dbt parse`, `dbt ls` and a `dbt test` without the model leave it out"
213
+ )
214
+ if m.materialized == "incremental":
215
+ res.notes.append("an incremental model: checked as its full refresh, as compiled")
216
+ inputs = [self.p.relations[d] for d in m.depends_on if d in self.p.relations]
217
+ for r in inputs:
218
+ why = self._ensure_relation(r)
219
+ if why:
220
+ return res.refuse(f"it reads {why}")
221
+ try:
222
+ res.sql = _rewrite(
223
+ m.compiled_code,
224
+ {r.relation_name: self.names[r.unique_id] for r in inputs if r.relation_name},
225
+ )
226
+ except _Unreadable as e:
227
+ return res.refuse(str(e))
228
+ try:
229
+ out = self.con.execute(f"DESCRIBE {res.sql}").fetchall()
230
+ except duckdb.Error as e:
231
+ res.sql = None
232
+ return res.refuse(f"DuckDB cannot plan the compiled SQL: {_first_line(e)}")
233
+ res.duckdb_types = {row[0]: row[1] for row in out}
234
+ # sverify reads DuckDB's type names (`verify(tables=...)`).
235
+ tables = {
236
+ self.names[r.unique_id]: {
237
+ row[0]: row[1]
238
+ for row in self.con.execute(
239
+ f"DESCRIBE SELECT * FROM {_q(self.names[r.unique_id])}"
240
+ ).fetchall()
241
+ }
242
+ for r in inputs
243
+ }
244
+ requires, res.not_assumed = self._requires(m, inputs)
245
+ ensures = self._ensures(m, res)
246
+ report = self._verify(res, tables, requires, ensures)
247
+ if report is None:
248
+ return res
249
+ self._read(report, m, res, ensures)
250
+ return res
251
+
252
+ def _ensures(self, m: Relation, res: ModelResult) -> list[tuple[Any, list]]:
253
+ """The conditions to prove, each with its test or constraint result."""
254
+ cols = set(res.duckdb_types)
255
+ out: list[tuple[Any, list]] = []
256
+ for t in self.p.tests_on(m.unique_id):
257
+ tr = TestResult(test=t.unique_id, name=t.name, label=t.short, kind="test")
258
+ res.tests.append(tr)
259
+ got = mapping.test_conditions(t, side="ensures", table_of=lambda _: None)
260
+ missing = [c for c in got.columns if c not in cols]
261
+ if missing:
262
+ tr.skip(f"the model has no column {', '.join(missing)}")
263
+ elif not got.conditions:
264
+ tr.skip(got.reason or "no condition")
265
+ else:
266
+ tr.conditions = got.conditions
267
+ out.append((tr, got.conditions))
268
+ if m.contract:
269
+ pairs = [(c.name, k) for c in m.columns.values() for k in c.constraints]
270
+ pairs += [(None, k) for k in m.constraints]
271
+ for col, k in pairs:
272
+ tr = TestResult(
273
+ test=None, name=None, label=mapping.constraint_label(k, col), kind="constraint"
274
+ )
275
+ res.constraints.append(tr)
276
+ got = mapping.constraint_conditions(k, col)
277
+ missing = [c for c in got.columns if c not in cols]
278
+ if missing:
279
+ tr.skip(f"the model has no column {', '.join(missing)}")
280
+ elif not got.conditions:
281
+ tr.skip(got.reason or "no condition")
282
+ else:
283
+ tr.conditions = got.conditions
284
+ out.append((tr, got.conditions))
285
+ return out
286
+
287
+ def _verify(self, res: ModelResult, tables: dict, requires: dict, ensures: list) -> Any:
288
+ """`sv.verify` of the model; a condition sverify cannot read is
289
+ dropped with its reason, and a model it cannot read is refused."""
290
+ conds = [c for _, cs in ensures for c in cs]
291
+
292
+ def go(req: dict, ens: list) -> Any:
293
+ with warnings.catch_warnings():
294
+ warnings.simplefilter("ignore")
295
+ return sv.verify(
296
+ res.sql, # type: ignore[arg-type]
297
+ tables=tables,
298
+ requires=req,
299
+ ensures=ens,
300
+ semantics="duckdb",
301
+ on_inferred="error",
302
+ counterexamples=self.counterexamples,
303
+ )
304
+
305
+ try:
306
+ return go(requires, conds)
307
+ except ValueError as e: # sverify's Unsupported, and a bad condition
308
+ first = e
309
+ try:
310
+ go({t: [] for t in requires}, [])
311
+ except ValueError as e:
312
+ res.refuse(_refusal(e))
313
+ return None
314
+ # The SQL is fine: find the conditions sverify cannot read.
315
+ for t, cs in requires.items():
316
+ keep = []
317
+ for c in cs:
318
+ try:
319
+ go({**{k: [] for k in requires}, t: [c]}, [])
320
+ keep.append(c)
321
+ except ValueError as e:
322
+ res.not_assumed.append(f"{t}: {_label(c)}: sverify cannot read it ({e})")
323
+ requires[t] = keep
324
+ for tr, cs in ensures:
325
+ try:
326
+ go({t: [] for t in requires}, cs)
327
+ except ValueError as e:
328
+ tr.skip(f"sverify cannot read its condition: {_first_line(e)}")
329
+ conds = [c for tr, cs in ensures if tr.status != "not checked" for c in cs]
330
+ try:
331
+ return go(requires, conds)
332
+ except ValueError:
333
+ res.refuse(_refusal(first))
334
+ return None
335
+
336
+ def _read(self, report: Any, m: Relation, res: ModelResult, ensures: list) -> None:
337
+ # `report.ensures` is in the order of the conditions given, each
338
+ # with its condition (`CheckedCondition.condition`).
339
+ results = iter(report.ensures)
340
+ needed = report.needed
341
+ res.needed = _needed(report, self, m)
342
+ res.may_fail = [str(i) for i in report.errors]
343
+ res.deterministic = report.deterministic
344
+ if not report.deterministic:
345
+ res.notes.append(
346
+ "rows may tie in an ORDER BY of a window or ordered aggregate, so DuckDB "
347
+ "picks the order: a test that depends on it is not proved"
348
+ )
349
+ res.assumed = [i.shown or i.expr for i in report.assumed]
350
+ for tr, cs in ensures:
351
+ if tr.status == "not checked":
352
+ continue
353
+ checked = [next(results) for _ in cs]
354
+ if any(r.condition is not c for r, c in zip(checked, cs, strict=True)):
355
+ tr.skip("sverify reported on another condition")
356
+ continue
357
+ bad = [c for c in checked if not c.portable]
358
+ if not bad and needed:
359
+ tr.status = "not proved"
360
+ tr.why = "proved only with the source tests listed under 'needs'"
361
+ elif not bad:
362
+ tr.status = "proved"
363
+ else:
364
+ c = bad[0]
365
+ tr.status = "not proved"
366
+ tr.why = _why(c)
367
+ tr.counterexample = str(c.counterexample) if c.counterexample else None
368
+ tr.would_hold = {
369
+ _user_table(t, self): [mapping.as_dbt_test(_sql(x)) for x in xs]
370
+ for t, xs in (c.suggested or {}).items()
371
+ }
372
+ if tr.status == "proved" and tr.test:
373
+ self.proved.add(tr.test)
374
+ if m.contract:
375
+ res.contract = self._contract(report, m, res)
376
+
377
+ def _contract(self, report: Any, m: Relation, res: ModelResult) -> list[ContractIssue]:
378
+ """The declared columns and types against the proved output schema
379
+ (`Report.output`) and DuckDB's own types for the compiled SQL."""
380
+ issues: list[ContractIssue] = []
381
+ proved = {c: _duck_name(self.con, _ibis_to_duckdb(t)) for c, t in report.output.items()}
382
+ actual = {c: _duck_name(self.con, t) for c, t in res.duckdb_types.items()}
383
+ declared = {c.name: c.data_type for c in m.columns.values()}
384
+ for c in declared:
385
+ if c not in actual:
386
+ issues.append(ContractIssue(c, "declared, but the model has no such column"))
387
+ for c in actual:
388
+ if c not in declared:
389
+ issues.append(ContractIssue(c, "in the model's result, but not declared"))
390
+ for c, t in declared.items():
391
+ if c not in actual:
392
+ continue
393
+ if t is None:
394
+ issues.append(ContractIssue(c, "declared without a data_type"))
395
+ continue
396
+ want = _duck_name(self.con, t)
397
+ if want != actual[c]:
398
+ issues.append(ContractIssue(c, f"declared {t}, but DuckDB gives {actual[c]}"))
399
+ if proved.get(c) and proved[c] != actual[c]:
400
+ res.notes.append(
401
+ f"column {c}: sverify's plan has type {proved[c]}, and DuckDB's SQL gives "
402
+ f"{actual[c]}; the contract check uses DuckDB's"
403
+ )
404
+ return issues
405
+
406
+
407
+ # ---- helpers ------------------------------------------------------------------
408
+
409
+
410
+ class _Unreadable(Exception):
411
+ pass
412
+
413
+
414
+ def _input_names(relations: Iterable[Relation]) -> dict[str, str]:
415
+ """A table name per relation, unique across the project: the model's or
416
+ seed's name, a source's table name, or `<source>__<table>` where two
417
+ collide."""
418
+ rels = list(relations)
419
+ counts: dict[str, int] = {}
420
+ for r in rels:
421
+ counts[r.name] = counts.get(r.name, 0) + 1
422
+ out = {}
423
+ for r in rels:
424
+ name = r.name
425
+ if counts[name] > 1 and r.resource_type == "source":
426
+ name = f"{r.source_name}__{r.name}"
427
+ if counts[r.name] > 1 and r.resource_type in ("seed", "snapshot"):
428
+ name = f"{r.resource_type}__{r.name}"
429
+ out[r.unique_id] = name
430
+ return out
431
+
432
+
433
+ def _parts(t: sg.exp.Table) -> tuple[str, ...]:
434
+ return tuple(p.lower() for p in (t.catalog, t.db, t.name) if p)
435
+
436
+
437
+ def _rewrite(sql: str, inputs: dict[str, str]) -> str:
438
+ """`sql` with each relation of `inputs` (dbt's `relation_name`) read by
439
+ its input name, keeping what the query calls it (its alias, else its
440
+ table name)."""
441
+ try:
442
+ tree = sg.parse_one(sql, read="duckdb")
443
+ except sg.errors.ParseError as e:
444
+ raise _Unreadable(f"the compiled SQL does not parse: {_first_line(e)}") from e
445
+ wanted = {}
446
+ for rel, name in inputs.items():
447
+ t = sg.exp.to_table(rel, dialect="duckdb")
448
+ wanted[_parts(t)] = name
449
+ ctes = {c.alias_or_name.lower() for c in tree.find_all(sg.exp.CTE)}
450
+
451
+ def find(t: sg.exp.Table) -> str | None:
452
+ parts = _parts(t)
453
+ if parts in wanted:
454
+ return wanted[parts]
455
+ hits = [n for p, n in wanted.items() if p[-len(parts) :] == parts]
456
+ return hits[0] if len(hits) == 1 else None
457
+
458
+ unknown = []
459
+ for t in list(tree.find_all(sg.exp.Table)):
460
+ if not isinstance(t.this, sg.exp.Identifier):
461
+ continue
462
+ if not t.db and not t.catalog and t.name.lower() in ctes:
463
+ continue
464
+ name = find(t)
465
+ if name is None:
466
+ unknown.append(t.sql("duckdb"))
467
+ continue
468
+ alias = t.alias or t.name
469
+ new = sg.exp.to_table(_q(name), dialect="duckdb")
470
+ if alias != name:
471
+ new.set("alias", sg.exp.TableAlias(this=sg.exp.to_identifier(alias)))
472
+ t.replace(new)
473
+ if unknown:
474
+ raise _Unreadable(
475
+ f"it reads {', '.join(dict.fromkeys(unknown))}, which is no ref or source of the "
476
+ "model (a hard-coded table name, or one a macro wrote)"
477
+ )
478
+ return tree.sql("duckdb")
479
+
480
+
481
+ def _q(name: str) -> str:
482
+ return '"' + name.replace('"', '""') + '"'
483
+
484
+
485
+ def _first_line(e: BaseException) -> str:
486
+ lines = str(e).strip().splitlines()
487
+ return lines[0] if lines else type(e).__name__
488
+
489
+
490
+ def _refusal(e: BaseException) -> str:
491
+ """sverify's refusal of a model, in the user's terms."""
492
+ msg = _first_line(e)
493
+ m = re.match(r"(.*?) is not supported(?: yet)?;?(.*)", msg)
494
+ if m:
495
+ what = m[1].strip()
496
+ if len(what) > 70:
497
+ what = what[:67] + "..."
498
+ rest = m[2].strip()
499
+ return f"sverify cannot read {what} yet" + (f" ({rest})" if rest else "")
500
+ return f"sverify cannot check it: {msg}"
501
+
502
+
503
+ _WHY = {
504
+ "observation": "proved only for some row orders DuckDB may pick",
505
+ "not_a_key": "the tests upstream do not make it a key",
506
+ "ties": "rows may tie in an ORDER BY, so DuckDB picks which rows it keeps",
507
+ "always_false": "every row fails it",
508
+ "may_be_false": "sverify cannot rule out a row that fails it, from the tests upstream",
509
+ "may_be_null": "sverify cannot rule out a row that fails it, from the tests upstream",
510
+ "not_proved_kind": "sverify checks this kind of condition on data only",
511
+ }
512
+
513
+
514
+ def _why(c: Any) -> str:
515
+ """Why a condition is not proved, in a dbt user's terms
516
+ (`CheckedCondition.reason`; its `why` speaks of `sv.unique` and
517
+ `requires`)."""
518
+ return _WHY.get(c.reason, f"sverify only knows it is {c.value}")
519
+
520
+
521
+ def _label(c: Any) -> str:
522
+ return c if isinstance(c, str) else c.label
523
+
524
+
525
+ def _sql(c: Any) -> str:
526
+ """A suggested condition as SQL, as far as it can be."""
527
+ return str(c) if isinstance(c, str) else getattr(c, "label", str(c))
528
+
529
+
530
+ def _user_table(name: str, checker: _Checker) -> str:
531
+ for uid, n in checker.names.items():
532
+ if n == name:
533
+ return checker.p.relations[uid].ref
534
+ return name
535
+
536
+
537
+ def _needed(report: Any, checker: _Checker, m: Relation) -> list[Needed]:
538
+ """`Report.needs` as tests to add to the inputs."""
539
+ out = []
540
+ for n in report.needs:
541
+ issue = n.issue
542
+ out.append(
543
+ Needed(
544
+ on=_user_table(n.table, checker),
545
+ column=n.column,
546
+ test=mapping.dbt_test(n.column, n.op, n.value, n.sql),
547
+ because=(
548
+ f"{issue.kind} in `{issue.shown or issue.expr}` otherwise" if issue else None
549
+ ),
550
+ condition=n.sql,
551
+ )
552
+ )
553
+ return out
554
+
555
+
556
+ def _ibis_to_duckdb(t: Any) -> str:
557
+ from ibis.backends.sql.datatypes import DuckDBType
558
+
559
+ return DuckDBType.to_string(t)
560
+
561
+
562
+ def _duck_name(con: duckdb.DuckDBPyConnection, t: str) -> str:
563
+ """DuckDB's own name of a type: `int` is INTEGER, `text` VARCHAR."""
564
+ try:
565
+ row = con.execute(f"SELECT typeof(CAST(NULL AS {t}))").fetchone()
566
+ return str(row[0]) if row else t.upper()
567
+ except duckdb.Error:
568
+ return t.upper()