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 из 0
Концептуальный
Вопрос 1 из 4. Как DuckDB позволяет запросить Pandas DataFrame SQL-запросом просто по имени переменной?

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

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

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

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