Перейти к содержанию
Learning Platform
Глоссарий Troubleshooting
Урок 12.01 · 21 мин
Средний
pythonreplacement-scanpandaspolars

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 подменяет ссылку на несуществующую таблицу сканированием этого объекта прямо в памяти. Дальше по конвейеру идёт обычный план запроса.

Replacement scan для Python-переменной
Имя 'sales' в FROMВ SQL-запросе используется имя, совпадающее с именем переменной Python
не найдено в каталоге
Python replacement scan callbackCallback Python-клиента заглядывает в локальные и глобальные переменные вызывающего кода
нашёл DataFrame
Сканирование объекта в памятиDataFrame читается прямо из памяти как таблица; конвертации в файл или копирования нет

Ключевое слово — в памяти. 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 её не найдёт, и запрос упадёт с ошибкой «таблица не существует».

WARNING

Совпадение имён означает и риск коллизии. Если у вас есть переменная 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-объект в каталоге соединения. Это пригодится и для регистрации произвольных объектов — детально об этом в последнем уроке модуля.

Два пути: replacement scan и явная регистрация
Replacement scanАвтоматически: имя таблицы в SQL совпадает с именем переменной Python, callback находит её сам
или
con.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.

  1. Создайте Pandas DataFrame с парой колонок. Выполните над ним duckdb.sql("SELECT ... FROM имя_переменной") с группировкой. Убедитесь, что переменная работает как таблица без всякой регистрации.
  2. Создайте те же данные как Polars DataFrame и как PyArrow Table. Выполните над каждым один и тот же SQL-запрос. Убедитесь, что синтаксис не зависит от типа DataFrame.
  3. Объявите DataFrame внутри одной функции и попробуйте запросить его из duckdb.sql(), вызванного в другой функции. Получите ошибку «таблица не существует». Объясните себе, почему — где callback ищет переменные.
  4. Создайте настоящую таблицу DuckDB (CREATE TABLE t AS SELECT 1 AS x) и одновременно Python-переменную t с другим DataFrame. Выполните SELECT * FROM t и посмотрите, что вернулось. Объясните, почему сработал каталог, а не replacement scan.

pandas: что такое DataFrame и почему DuckDB умеет его читать напрямую
Проверка знанийKnowledge check
Как DuckDB позволяет запросить Pandas или Polars DataFrame SQL-запросом просто по имени переменной, и почему при этом не происходит копирования данных в файл или отдельную таблицу?
ОтветAnswer
Это работает за счёт того же механизма replacement scan, что и для файлов, но в его Python-варианте. Когда DuckDB разбирает SQL-запрос и binder встречает имя в позиции FROM, которого нет в каталоге, он опрашивает зарегистрированные replacement scan callbacks. Python-клиент DuckDB регистрирует специальный callback, который заглядывает в Python-окружение — в локальные и глобальные переменные той области кода, откуда вызван запрос. Callback находит переменную с совпадающим именем, проверяет её тип, и если это Pandas DataFrame, Polars DataFrame или PyArrow Table, подменяет ссылку на несуществующую таблицу сканированием этого объекта прямо в памяти. Копирования в файл или отдельную таблицу не происходит, потому что DuckDB — embedded-движок: он работает внутри того же Python-процесса, и память Python-объектов ему напрямую доступна. DataFrame читается там, где он уже лежит — в адресном пространстве процесса. У клиент-серверной СУБД так не получилось бы: данные пришлось бы сериализовать и переслать по сети. Дополнительно чтение дёшево потому, что Polars и PyArrow держат данные в памяти в колоночном формате Apache Arrow, а современный Pandas умеет отдавать содержимое в Arrow-представлении; DuckDB внутри тоже колоночный, поэтому прочитать DataFrame для него — во многом просто посмотреть на готовые колоночные буферы. Важные ограничения: имя таблицы в SQL должно буквально совпадать с именем переменной Python, переменная должна быть в области видимости вызова, а если в каталоге DuckDB уже есть настоящая таблица с тем же именем, приоритет у неё — replacement scan срабатывает только на ненайденное имя. Когда автоматического распознавания недостаточно, есть явная регистрация через con.register(имя, объект).

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 6