Перейти к содержанию
Learning Platform
Глоссарий Troubleshooting
Урок 09.03 · 22 мин
Продвинутый
AdapterCredentialsProfilesDataclass

Credentials dataclass: схема profiles.yml

Когда пользователь пишет в profiles.yml:

my_project:
  outputs:
    dev:
      type: oceanbase
      host: localhost
      port: 2881
      user: root
      database: test

dbt должен превратить это в Python-объект. Этим занимается Credentials dataclass. В этом уроке — полный разбор: какие поля обязательны, что такое ALIASES, как _connection_keys влияет на caching, что такое unique_field.


@dataclass: автогенерация __init__ и field defaults env_var(): чтение environment variables и секреты (dbt I)

Базовая структура

# dbt-oceanbase/dbt/adapters/oceanbase/credentials.py
from dataclasses import dataclass, field
from typing import Optional

from dbt.adapters.contracts.connection import Credentials


@dataclass
class OceanBaseCredentials(Credentials):
    # Required positional args (no default)
    host: str
    
    # Optional with defaults
    port: int = 2881
    user: str = 'root'
    password: str = ''
    database: str = 'test'
    schema: str = ''
    
    # Standard dbt fields (inherited from Credentials)
    # database, schema — already в parent class
    
    # Adapter-specific
    tenant: Optional[str] = None
    cluster: Optional[str] = None
    threads: int = 4
    
    @property
    def type(self) -> str:
        return 'oceanbase'
    
    @property
    def unique_field(self) -> str:
        return self.host
    
    def _connection_keys(self):
        return ('host', 'port', 'user', 'database', 'schema', 'tenant')

Разберём каждую часть.


Поля dataclass

Поля определяют структуру profiles.yml. Каждое поле в dataclass = один key в YAML.

@dataclass
class OceanBaseCredentials(Credentials):
    host: str                            # Required — no default
    port: int = 2881                     # Optional — default 2881
    user: str = 'root'
    password: str = ''
    database: str = 'test'
    schema: str = ''
    tenant: Optional[str] = None         # Может быть None
    cluster: Optional[str] = None
    threads: int = 4

Required vs Optional:

  • Required field — без default value. Если пользователь не укажет в profiles.yml, dbt fails с missing required field: host.
  • Optional field — с default. Можно опустить в YAML.

Stand-я поля (inherited from base Credentials):

  • database — name of database (Snowflake’s DB, Postgres’s db, BigQuery’s project)
  • schema — name of default schema
  • threads — count of parallel threads для dbt run

Вы должны включить database и schema. Это используется dbt-core’s CLI и internal logic.

Adapter-specific — что хотите. Для OceanBase это tenant, cluster. Для Snowflake — account, warehouse, role. Для BigQuery — project, keyfile.


ALIASES — синонимы для полей

Иногда пользователи привыкли к другим именам полей из похожих warehouses. Например, MySQL пользователи знают username, dbt’s standard — user. Можно поддержать оба:

@dataclass
class OceanBaseCredentials(Credentials):
    host: str
    user: str = 'root'
    
    # ALIASES — alternative names accepted в profiles.yml
    ALIASES = {
        'username': 'user',          # username -> user
        'pass': 'password',           # pass -> password
        'db': 'database',             # db -> database
    }

Теперь оба работают:

# Standard
dev:
  type: oceanbase
  user: alice
  database: analytics

# С aliases
dev:
  type: oceanbase
  username: alice    # ← treated as 'user'
  db: analytics       # ← treated as 'database'

dbt при загрузке profiles.yml применяет ALIASES до создания dataclass.

Use case:

  • Migration friendly: пользователи переходящие с другого dbt-adapter, привыкли к alternative names.
  • Multiple конвенций: warehouse имеет два standard, оба должны работать.

Best practice: не использовать ALIASES без причины. Single canonical name проще для документации. Используйте только когда есть strong reason (migration path).


type property — идентификатор adapter

@property
def type(self) -> str:
    return 'oceanbase'

Это критический property. Используется:

  1. profiles.yml type field должно совпадать:

    dev:
      type: oceanbase   # ← должно совпадать с self.type
  2. AdapterPlugin registration — dbt-core хранит plugins по type:

    FACTORY.plugins = {
        'oceanbase': OceanBasePlugin,
        'snowflake': SnowflakePlugin,
        ...
    }
  3. Dispatch macros: <type>__macro_name — например, oceanbase__list_schemas. dbt использует self.type чтобы определить, какие macros искать.

Naming convention: lowercase, no spaces, no underscores в начале. Match имя pip-package: dbt-oceanbase -> type oceanbase.


unique_field — для статистики

@property
def unique_field(self) -> str:
    return self.host

Этот property возвращает строку, идентифицирующую deploy. Используется dbt Labs для анонимной статистики (опционально, можно disable).

Для каждого adapter unique_field обычно:

  • Postgres: host
  • Snowflake: account (e.g., ‘xy12345.us-east-1’)
  • BigQuery: project
  • DuckDB: path
  • OceanBase: host

Зачем: dbt анонимизирует данные о использовании, sending к telemetry endpoint. unique_field ХЕШИРУЕТСЯ перед отправкой — dbt не видит actual host/account, видит только хеш. Цель: посчитать кол-во unique deploys, не identifying specific user.

Privacy: пользователи могут отключить telemetry в ~/.dbt/profiles.yml:

config:
  send_anonymous_usage_stats: False

Тогда unique_field не отправляется куда-либо.


_connection_keys — для caching

def _connection_keys(self):
    return ('host', 'port', 'user', 'database', 'schema', 'tenant')

Этот method возвращает tuple полей, которые идентифицируют unique connection. dbt использует это для caching connections.

Зачем:

dbt поддерживает threading — несколько моделей могут выполняться parallel. Каждый thread имеет свой connection. Если несколько threads запрашивают connection с одинаковыми credentials — dbt reuses connection из pool.

Cache key для connection — это hash of _connection_keys values.

Example:

# Thread 1: connection с (host='localhost', port=2881, user='alice', db='analytics', schema='dev', tenant=None)
# Thread 2: connection с тем же — REUSES from cache

# Thread 3: connection с (host='localhost', port=2881, user='alice', db='analytics', schema='prod', tenant=None)
#   ← different schema -> DIFFERENT connection (нужен новый)

Что включать в _connection_keys:

  • Yes: host, port, user, database, schema (главные)
  • Yes: warehouse-specific identifiers: tenant, role, warehouse (Snowflake), project (BigQuery)
  • No: password (не identifies connection, identifies user — privacy)
  • No: threads (это runtime config, не connection identity)

Common mistake: забыть включить warehouse-specific identifier. Например, на Snowflake забыть role — тогда два разных role’а share connection, что приводит к permission bugs.


Полный пример — Snowflake

Для сравнения, реальный Snowflake credentials:

# dbt-snowflake/dbt/adapters/snowflake/connections.py (упрощённо)
@dataclass
class SnowflakeCredentials(Credentials):
    account: str
    user: str
    
    # Auth options (one of these required)
    password: Optional[str] = None
    private_key: Optional[str] = None
    private_key_path: Optional[str] = None
    private_key_passphrase: Optional[str] = None
    authenticator: Optional[str] = None
    oauth_client_id: Optional[str] = None
    oauth_client_secret: Optional[str] = None
    token: Optional[str] = None
    
    # Connection options
    warehouse: Optional[str] = None
    role: Optional[str] = None
    database: Optional[str] = None
    schema: Optional[str] = None
    
    # Behavior
    client_session_keep_alive: bool = False
    query_tag: Optional[str] = None
    connect_retries: int = 1
    connect_timeout: Optional[int] = None
    
    ALIASES = {
        'auth_user': 'user',
        'sf_account': 'account',
    }
    
    @property
    def type(self) -> str:
        return 'snowflake'
    
    @property
    def unique_field(self) -> str:
        return self.account
    
    def _connection_keys(self):
        return (
            'account', 'user', 'role', 'warehouse',
            'database', 'schema', 'authenticator',
        )

Видны несколько паттернов:

  1. Multiple auth methods: password, private_key, OAuth, SSO. Snowflake supports все из них.
  2. Optional fields: большинство Optional, потому что разные auth methods требуют разный набор.
  3. Behavior knobs: client_session_keep_alive, query_tag, connect_retries — runtime options.
  4. ALIASES: для migration от dbt-cloud (which used different field names исторически).

Validation в credentials

Иногда нужно validate credentials. Например: «password ИЛИ private_key должен быть указан». dbt предоставляет __post_init__:

@dataclass
class OceanBaseCredentials(Credentials):
    host: str
    user: str = 'root'
    password: Optional[str] = None
    auth_token: Optional[str] = None
    
    def __post_init__(self):
        # Validation после dataclass init
        if not self.password and not self.auth_token:
            raise ValueError(
                'OceanBaseCredentials requires either password or auth_token'
            )
        
        if self.password and self.auth_token:
            raise ValueError(
                'OceanBaseCredentials: provide either password or auth_token, not both'
            )

__post_init__ вызывается dataclass’ом после standard init. Хорошее место для cross-field validation.

Best practice:

  • Validate at parse time (когда credentials loaded), не at connection time
  • Provide clear error messages
  • Don’t hide credentials в error messages

profile_template.yml

Cookiecutter scaffold генерирует dbt/include/oceanbase/profile_template.yml:

# profile_template.yml
fixed:
  type: oceanbase

prompts:
  host:
    hint: 'The hostname for the OceanBase instance'
  port:
    default: 2881
    hint: 'The port for the OceanBase instance'
  user:
    hint: 'Username для authentication'
  password:
    hint: 'Password для user'
    hide_input: true
  database:
    hint: 'Database name'
  schema:
    hint: 'Schema name'
  threads:
    default: 4
    hint: 'Number of threads'

Этот файл используется командой dbt init — interactive setup для нового проекта. dbt спрашивает у пользователя поля по template.

dbt init my_oceanbase_project --adapter oceanbase
# Interactive prompts:
# host: localhost
# port [2881]:
# user: alice
# password [hidden]: ********
# database: analytics
# schema: dev
# threads [4]:

Создаёт правильный profiles.yml. Полезно для UX.


Что happens при загрузке profiles.yml

Lifecycle credentials loading:

Credentials loading lifecycle
dbt run / debug / любая командаdbt CLI запускается с --profile my_project. dbt находит ~/.dbt/profiles.yml.
Parse profiles.yml, eval Jinjadbt парсит YAML файл в Python dict. Применяет `{{ env_var() }}` и др. Jinja-функции.
Find adapter by typedbt смотрит type: 'oceanbase' -> находит зарегистрированный AdapterPlugin -> берёт класс OceanBaseCredentials.
Apply ALIASESПрименяются ALIASES — переименование полей. db -> database, username -> user, etc.
Create dataclass instanceOceanBaseCredentials(host=..., port=..., user=...). Если missing required field — ошибка.
Run __post_init__ validation__post_init__ выполняется. Custom validation (e.g., password XOR auth_token).
Pass to ConnectionManager.open()Credentials передаётся в ConnectionManager. Используется в open() для actual connection.

Этот lifecycle — same для всех adapter’ов. Customization точки:

  • Field definitions — define schema
  • ALIASES — accept alternative names
  • __post_init__ — cross-field validation
  • _connection_keys — каждое поле для cache identity

env_var() в profiles.yml

Пользователи часто используют env_var для secrets:

dev:
  type: oceanbase
  host: '{{ env_var("OB_HOST") }}'
  password: '{{ env_var("OB_PASSWORD") }}'

dbt evaluates Jinja до dataclass creation. То есть OceanBaseCredentials(host=os.environ['OB_HOST']), не host='{{ env_var(...) }}'.

В credentials class нет ничего special для env_var — это работает via dbt-core’s Jinja evaluation.


Попробуй сам

  1. Создайте credentials.py с базовой структурой:

    from dataclasses import dataclass
    from dbt.adapters.contracts.connection import Credentials
    
    @dataclass
    class MyAdapterCredentials(Credentials):
        host: str
        port: int = 5432
        user: str = 'admin'
        password: str = ''
        database: str = 'mydb'
        schema: str = 'public'
        
        @property
        def type(self) -> str:
            return 'myadapter'
        
        @property
        def unique_field(self) -> str:
            return self.host
        
        def _connection_keys(self):
            return ('host', 'port', 'user', 'database', 'schema')
  2. Создайте profiles.yml:

    test_project:
      target: dev
      outputs:
        dev:
          type: myadapter
          host: localhost
          user: alice
          database: testdb
          schema: dev
  3. Добавьте ALIASES — позвольте username instead of user:

    ALIASES = {
        'username': 'user',
    }
  4. Добавьте post_init validation:

    def __post_init__(self):
        if not self.password and 'PASSWORD' not in os.environ:
            # Either explicit password or env var
            pass
  5. Test loading через dbt parse. Если credentials structure правильная — должно работать.


Ключевые выводы

  1. Credentials dataclass определяет схему profiles.yml. Required (no default) vs optional (with default) fields.

  2. type property — identifier adapter. Должно совпадать с profiles.yml type field и pip-package name.

  3. unique_field — для анонимной статистики. Обычно host / account / project.

  4. _connection_keys — поля для connection caching. Включайте все, что identifies unique connection (host, port, user, db, schema, role, warehouse).

  5. ALIASES — alternative field names. Не используйте без причины.

  6. __post_init__ — для cross-field validation. Clear error messages.

  7. profile_template.yml — для dbt init interactive setup. UX-friendly.

Проверка знанийKnowledge check
Senior спрашивает: почему важно правильно настроить _connection_keys для Snowflake adapter?
ОтветAnswer
`_connection_keys` определяет **connection caching identity**. На Snowflake это критично для performance и correctness.\n\n**Зачем connection caching**:\n\nDBT с `threads: 8` запускает 8 параллельных threads. Каждый thread нужен database connection. Создание connection — slow (TLS handshake, auth):\n\n- DuckDB: ~50ms\n- Snowflake: ~1-3 секунды\n- BigQuery: ~500ms\n\nЕсли каждый thread создаёт connection from scratch — 8 connections × 2 секунды = 16 seconds startup. Если cached — 1 connection setup, 8 reuse.\n\n**Cache key — это hash _connection_keys**:\n\n```python\ndef _connection_keys(self):\n return ('account', 'user', 'role', 'warehouse', 'database', 'schema')\n```\n\nthread 1 запрашивает connection: hash('xy12345', 'alice', 'analyst', 'wh1', 'analytics', 'dev') = ABCD\n\nthread 2 запрашивает то же: hash = ABCD -> reuse from cache\n\nthread 3 другой schema: hash('xy12345', 'alice', 'analyst', 'wh1', 'analytics', 'prod') = WXYZ -> new connection\n\n**Что happens если забыли поле**:\n\nПредположим, забыли `role` в _connection_keys:\n\n```python\ndef _connection_keys(self):\n return ('account', 'user', 'warehouse', 'database', 'schema')\n # role missing!\n```\n\nТеперь:\n\n- thread 1: role='analyst', hash = AAAA\n- thread 2: role='developer', hash = AAAA (same — role не в keys!)\n- thread 2 **reuses** connection from thread 1!\n\n**Bug**: thread 2 хочет run query как 'developer', но получает connection с role='analyst'. Permission denied или wrong результаты.\n\n**Concrete Snowflake symptom**:\n\n```sql\n-- Thread 1 (analyst): SET ROLE analyst_role;\n-- Connection cached.\n\n-- Thread 2 (developer): needs role=developer\n-- Reuses connection. Still in analyst_role context.\n-- SELECT FROM sensitive_table -> access denied для analyst\n-- Error: 'SQL access control error: Insufficient privileges'\n```\n\n**Worse — silent wrong results**:\n\nЕсли role analyst has SOME access to sensitive_table (limited rows), thread 2 thinks они querying как developer, но fact get analyst's view. **Wrong результаты**.\n\n**Правильный _connection_keys для Snowflake**:\n\n```python\ndef _connection_keys(self):\n return (\n 'account', # different deploys\n 'user', # different users\n 'role', # different roles — CRITICAL\n 'warehouse', # different compute pools\n 'database',\n 'schema',\n 'authenticator', # different auth methods (OAuth vs password)\n )\n```\n\n**Что НЕ включать**:\n\n- `password` / `private_key` — не identify connection, identify auth. Privacy issue (logs).\n- `threads` — runtime config, not connection identity.\n- `query_tag` — runtime metadata, не connection.\n- `client_session_keep_alive` — behavior flag, не identity.\n\n**Production-grade approach**:\n\nFor any new field в credentials, спросите: 'если это поле different, нужен ли different connection?'\n\n- Yes (например, role) -> include в _connection_keys\n- No (например, query_tag) -> exclude\n\n**Test для проверки**:\n\nWrite integration test:\n\n```python\ndef test_connection_caching_per_role():\n # 2 connections с разными roles\n conn1 = manager.get_thread_connection_credentials(role='analyst')\n conn2 = manager.get_thread_connection_credentials(role='developer')\n \n # Should be DIFFERENT connections\n assert conn1.handle != conn2.handle\n\ndef test_connection_caching_per_warehouse():\n conn1 = manager.get_thread_connection_credentials(warehouse='wh_small')\n conn2 = manager.get_thread_connection_credentials(warehouse='wh_large')\n \n assert conn1.handle != conn2.handle\n```\n\nЕсли test fails (same handle) — забыли field в _connection_keys.\n\n**В history dbt-snowflake** была эта bug в early versions. После того как pulled to public — heavy debugging, eventually fix включить `role`. Это известный gotcha.\n\n**Этот принцип — applies к любому warehouse**: BigQuery (project), Postgres (search_path), DuckDB (path), и т.д. Identify connection identity carefully.
Проверка знанийKnowledge check
При написании custom Postgres-like adapter, можно ли просто унаследовать от dbt-postgres credentials и расширить?
ОтветAnswer
**Технически да, но это anti-pattern**. Лучше — explicit copy с modifications.\n\n**Tempted approach**:\n\n```python\n# dbt-greenplum/dbt/adapters/greenplum/credentials.py\nfrom dbt.adapters.postgres.connections import PostgresCredentials\n\n@dataclass\nclass GreenplumCredentials(PostgresCredentials):\n # Inherit all Postgres fields\n # Add Greenplum-specific\n distribution_policy: Optional[str] = None\n \n @property\n def type(self) -> str:\n return 'greenplum'\n```\n\nЭто **работает**, но имеет проблемы.\n\n**Problem 1 — Tight coupling to dbt-postgres**:\n\nДепендеси: `dbt-greenplum` теперь requires `dbt-postgres` как dependency.\n\n```python\n# setup.py\ninstall_requires=[\n 'dbt-postgresне меньше1.8', # ← coupling\n ...\n],\n```\n\nЕсли dbt-postgres делает breaking change в credentials (add field, rename, etc.) — dbt-greenplum ломается тоже.\n\n**Production issue**: maintenance burden. Need track dbt-postgres releases.\n\n**Problem 2 — Behavior unexpected**:\n\nPostgresCredentials имеет много fields:\n\n```python\n@dataclass\nclass PostgresCredentials(Credentials):\n host: str\n port: int = 5432\n user: str\n password: str = ''\n database: str\n schema: str = 'public'\n search_path: Optional[str] = None\n role: Optional[str] = None\n sslmode: Optional[str] = None\n sslcert: Optional[str] = None\n sslkey: Optional[str] = None\n sslrootcert: Optional[str] = None\n application_name: Optional[str] = 'dbt'\n connect_timeout: Optional[int] = 10\n retries: int = 1\n keepalives_idle: Optional[int] = 0\n # ... etc\n```\n\nGreenplum может не support все эти (e.g., `sslmode` works differently, `application_name` ignored). User указывает в profiles.yml, ожидает работы — но silently ignored.\n\n**Confusing UX**.\n\n**Problem 3 — _connection_keys inherited**:\n\n```python\n# PostgresCredentials._connection_keys\ndef _connection_keys(self):\n return ('host', 'port', 'user', 'database', 'schema', 'search_path', 'role', 'sslmode')\n```\n\nGreenplum может ne использовать `search_path` или `sslmode`. Including в keys -> wasteful, possible incorrect.\n\n**Problem 4 — ALIASES carry over**:\n\nPostgres might have ALIASES (e.g., `db` -> `database`). Inherited. Greenplum users get unexpected behavior.\n\n**Better approach — explicit copy**:\n\n```python\n# dbt-greenplum/dbt/adapters/greenplum/credentials.py\nfrom dataclasses import dataclass, field\nfrom typing import Optional\nfrom dbt.adapters.contracts.connection import Credentials\n\n\n@dataclass\nclass GreenplumCredentials(Credentials):\n # Standard fields — copied, not inherited\n host: str\n port: int = 5432\n user: str = ''\n password: str = ''\n database: str = ''\n schema: str = 'public'\n \n # Postgres-compatible (since Greenplum based на Postgres)\n sslmode: Optional[str] = None\n connect_timeout: Optional[int] = 10\n \n # Greenplum-specific\n distribution_policy: Optional[str] = None\n segment_count: Optional[int] = None\n \n # NOT included (Greenplum не support):\n # - search_path (different mechanism)\n # - role (no roles в Greenplum)\n # - keepalives_idle (different network model)\n \n @property\n def type(self) -> str:\n return 'greenplum'\n \n @property\n def unique_field(self) -> str:\n return self.host\n \n def _connection_keys(self):\n return ('host', 'port', 'user', 'database', 'schema', 'distribution_policy')\n```\n\n**Why this is better**:\n\n1. **Explicit**: каждое поле intentional. Reader сразу видит supported options.\n\n2. **No coupling**: dbt-greenplum не depends на dbt-postgres. Breakage in postgres не affect.\n\n3. **Clear UX**: only relevant fields. Users не confused.\n\n4. **Maintainability**: own _connection_keys, own ALIASES, own validation.\n\n**Trade-off — code duplication**:\n\nDuplicating standard fields (host, port, etc.) seems wasteful. But:\n\n- Standard fields в `Credentials` base class (`database`, `schema`) inherited.\n- Other fields rarely changed. One-time copy.\n- Maintenance benefit outweighs duplication cost.\n\n**When inheritance OK**:\n\nOnly if adapter is **truly variation** of existing (e.g., Redshift = Postgres variant, both use same protocol):\n\n```python\n# dbt-redshift inherits from dbt-postgres\nclass RedshiftCredentials(PostgresCredentials):\n # Add Redshift-specific\n iam_profile: Optional[str] = None\n cluster_id: Optional[str] = None\n```\n\nThat's intentional — Redshift uses Postgres wire protocol, same fields apply.\n\nBut even then, **document the coupling**.\n\n**General rule**:\n\n- **Inherit**: when adapter is direct variant с identical protocol (Redshift from Postgres).\n- **Copy**: when adapter is similar но distinct (Greenplum from Postgres, despite SQL compatibility).\n- **From scratch**: для truly different (REST API, Spark).\n\nThis is **architectural decision**, не just code style.\n\nReal-world reference: dbt-redshift inherits from dbt-postgres because shared wire protocol. dbt-clickhouse extends SQLAdapter directly, не inherits Postgres, despite ClickHouse syntax similarity.

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

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

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

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