Replacement scans: запрос DataFrame прямо в SQL
В модуле про внешние данные мы видели, как SELECT * FROM 'file.parquet' превращает файл в таблицу через механизм replacement scan. У этого механизма есть вторая, не менее впечатляющая сторона — в Python. Если в вашем Python-коде есть переменная с Pandas, Polars или PyArrow-данными, вы можете запросить её SQL-запросом по имени переменной, ничего предварительно не регистрируя:
import duckdb
import pandas as pd
sales = pd.DataFrame({"product": ["A", "B", "A"], "amount": [100, 250, 75]})
# Запрос обычной Python-переменной по имени, прямо в SQL
result = duckdb.sql("SELECT product, SUM(amount) FROM sales GROUP BY product")
print(result)
┌─────────┬─────────────┐
│ product │ sum(amount) │
│ varchar │ int128 │
├─────────┼─────────────┤
│ A │ 175 │
│ B │ 250 │
└─────────┴─────────────┘
Имени sales нет ни в каком каталоге DuckDB. Это локальная переменная Python. И всё же она работает как таблица. Этот урок про то, как именно — потому что это не магия, а конкретный и понятный механизм, и понимать его важно для всей работы DuckDB с Python.
Что происходит: replacement scan в Python
Механизм тот же, что для файлов. Когда DuckDB разбирает запрос и binder встречает имя sales в позиции FROM, он не находит его в каталоге и опрашивает replacement scan callbacks. Python-клиент DuckDB регистрирует специальный callback. Этот callback делает то, чего файловый callback делать не может: он заглядывает в Python-окружение — в локальные и глобальные переменные того места, откуда вызван запрос.
Callback находит переменную sales, проверяет её тип. Если это Pandas DataFrame, Polars DataFrame или PyArrow Table — словом, объект, который DuckDB умеет читать, — callback подменяет ссылку на несуществующую таблицу сканированием этого объекта прямо в памяти. Дальше по конвейеру идёт обычный план запроса.
Ключевое слово — в памяти. DataFrame не сохраняется во временный файл, не конвертируется в .duckdb-таблицу. DuckDB читает его там, где он уже лежит — в адресном пространстве того же процесса. Это возможно именно потому, что DuckDB embedded: он работает внутри Python-процесса, и память Python-объектов ему доступна напрямую. У клиент-серверной СУБД такого быть не может — там данные пришлось бы сериализовать и переслать.
Три типа DataFrame, и почему это работает для всех
Replacement scan распознаёт три основных вида табличных объектов: Pandas DataFrame, Polars DataFrame и PyArrow Table. Pandas — историческая основа Python-аналитики. Polars — современная быстрая колоночная библиотека. PyArrow — реализация Apache Arrow.
import duckdb
import pandas as pd
import polars as pl
import pyarrow as pa
df_pandas = pd.DataFrame({"x": [1, 2, 3]})
df_polars = pl.DataFrame({"x": [10, 20, 30]})
tbl_arrow = pa.table({"x": [100, 200, 300]})
# Один и тот же синтаксис для всех трёх
duckdb.sql("SELECT SUM(x) FROM df_pandas")
duckdb.sql("SELECT SUM(x) FROM df_polars")
duckdb.sql("SELECT SUM(x) FROM tbl_arrow")
Почему DuckDB одинаково легко читает все три? Потому что и Polars, и PyArrow держат данные в памяти в формате Apache Arrow, а современный Pandas умеет отдавать своё содержимое в Arrow-представлении. Arrow — это колоночная раскладка, и DuckDB внутри тоже колоночный. Поэтому прочитать DataFrame для DuckDB — это во многом просто посмотреть на уже готовые колоночные буферы. Глубоко в zero-copy interop мы погрузимся в следующем уроке; пока достаточно знать, что общий колоночный формат — причина, по которой replacement scan дёшев.
Скоупинг: откуда берётся имя
Раз callback заглядывает в переменные «вызывающего кода» — важно понимать, какие именно переменные он видит. Он видит локальные и глобальные переменные той области, из которой вызван duckdb.sql(). Если запрос вызван внутри функции, доступны локальные переменные этой функции и глобальные модуля.
import duckdb
import pandas as pd
def analyze():
orders = pd.DataFrame({"id": [1, 2], "total": [500, 300]})
# orders — локальная переменная функции; replacement scan её видит
return duckdb.sql("SELECT SUM(total) AS revenue FROM orders").fetchone()
print(analyze()) # (800,)
Из этого следуют практические выводы. Имя таблицы в SQL и имя переменной в Python должны совпадать буквально. Если переменная вне области видимости — например объявлена в другой функции — replacement scan её не найдёт, и запрос упадёт с ошибкой «таблица не существует».
Совпадение имён означает и риск коллизии. Если у вас есть переменная sales и одновременно в базе DuckDB создана настоящая таблица sales, приоритет — у таблицы каталога. Replacement scan, как и для файлов, срабатывает только когда имя в каталоге не найдено. Поэтому DataFrame с именем существующей таблицы будет молча проигнорирован, а запрос обратится к таблице. Давайте переменным отчётливые имена, чтобы случайно не перекрыть их таблицами.
connect и связь с конкретным окружением
duckdb.sql(...) использует глобальное соединение и видит окружение в точке вызова. Часто нужно своё соединение — например для работы с конкретным файлом базы. Его создаёт duckdb.connect():
import duckdb
import pandas as pd
con = duckdb.connect("analytics.duckdb") # соединение с файлом базы
events = pd.DataFrame({"type": ["click", "view", "click"]})
# Запрос через соединение; replacement scan по-прежнему видит events
con.sql("SELECT type, COUNT(*) FROM events GROUP BY type")
Replacement scan работает и через явное соединение con.sql(...). Метод соединения тоже умеет осматривать вызывающее окружение и находить DataFrame по имени.
Если автоматического распознавания недостаточно — например DataFrame создаётся динамически и его нет в виде именованной переменной, или нужно дать таблице имя, отличное от имени переменной, — есть явная регистрация через con.register():
con.register("sales_view", events) # дать объекту явное имя в каталоге
con.sql("SELECT * FROM sales_view")
register создаёт именованную ссылку на Python-объект в каталоге соединения. Это пригодится и для регистрации произвольных объектов — детально об этом в последнем уроке модуля.
Почему это меняет рабочий процесс
Сравним с тем, как анализ DataFrame выглядел бы без replacement scan. Чтобы выполнить SQL над Pandas-данными в клиент-серверной СУБД, нужно: поднять сервер, создать таблицу, залить туда DataFrame построчно или через bulk-загрузку, выполнить запрос, забрать результат. Данные физически переезжают из Python в сервер и обратно. Это и долго, и громоздко.
Replacement scan убирает всё это. SQL и Python-объекты живут в одном процессе, в одной памяти. Можно свободно смешивать: посчитать что-то в Pandas, передать SQL-запросом в DuckDB для тяжёлой агрегации, результат снова забрать в DataFrame и продолжить в Pandas. Граница между «SQL-миром» и «Python-миром» становится почти невидимой — переменная просто доступна с обеих сторон. Это и есть причина, по которой DuckDB так удобен в Jupyter-ноутбуках и Python-пайплайнах.
Попробуй сам
Для задания нужен pip install duckdb pandas polars pyarrow.
- Создайте Pandas DataFrame с парой колонок. Выполните над ним
duckdb.sql("SELECT ... FROM имя_переменной")с группировкой. Убедитесь, что переменная работает как таблица без всякой регистрации. - Создайте те же данные как Polars DataFrame и как PyArrow Table. Выполните над каждым один и тот же SQL-запрос. Убедитесь, что синтаксис не зависит от типа DataFrame.
- Объявите DataFrame внутри одной функции и попробуйте запросить его из
duckdb.sql(), вызванного в другой функции. Получите ошибку «таблица не существует». Объясните себе, почему — где callback ищет переменные. - Создайте настоящую таблицу DuckDB (
CREATE TABLE t AS SELECT 1 AS x) и одновременно Python-переменнуюtс другим DataFrame. ВыполнитеSELECT * FROM tи посмотрите, что вернулось. Объясните, почему сработал каталог, а не replacement scan.
pandas: что такое DataFrame и почему DuckDB умеет его читать напрямую