feat(feishu): 添加飞书入站事件inbox和混合数据库协调功能

- 实现飞书入站事件持久化inbox机制,支持状态管理、租约锁定和重试退避
- 添加混合数据库基线协调工具,确保平台PostgreSQL结构安全对齐
- 增加运行组件心跳检测和readiness就绪检查机制
- 实现app_ticket事件的安全轮换和验证处理
- 添加生产环境运行编排和fail-closed安全机制
- 支持webhook快速确认和长连接独立进程处理
- 完善个人数据擦除时的待处理事件清理功能
```
This commit is contained in:
2026-07-27 17:14:37 +08:00
parent d7db84571d
commit eb8267ed18
61 changed files with 8703 additions and 183 deletions

View File

@@ -5,6 +5,7 @@ from sqlalchemy import engine_from_config, pool
from app.core.config import get_settings
from app.core.database import Base
from app.core.database.safety import validate_platform_migration_target
from app.modules.ai_memory import models as ai_memory_models
from app.modules.audit import models as audit_models
from app.modules.business import models as business_models
@@ -23,6 +24,10 @@ if config.config_file_name is not None:
target_metadata = Base.metadata
settings = get_settings()
validate_platform_migration_target(
settings.database_url,
settings.legacy_database_url,
)
# Keep imports referenced so SQLAlchemy model classes register with Base.metadata.
_REGISTERED_MODEL_MODULES = (
@@ -52,6 +57,20 @@ def run_migrations_offline() -> None:
def run_migrations_online() -> None:
supplied_connection = config.attributes.get("connection")
if supplied_connection is not None:
validate_platform_migration_target(
supplied_connection.engine.url,
settings.legacy_database_url,
)
context.configure(
connection=supplied_connection,
target_metadata=target_metadata,
)
with context.begin_transaction():
context.run_migrations()
return
configuration = config.get_section(config.config_ini_section, {})
configuration["sqlalchemy.url"] = settings.database_url
connectable = engine_from_config(

View File

@@ -0,0 +1,663 @@
"""Reconcile the known unversioned PostgreSQL schema.
Revision ID: 202607270001
Revises: 202607260005
Create Date: 2026-07-27
This revision is intentionally idempotent. A normally versioned database at
202607260005 only receives the PostgreSQL NULLS NOT DISTINCT hardening. The
one-time baseline runner may also use it, in the same transaction as an atomic
stamp, to repair the explicitly allowlisted mixed schema.
"""
from collections.abc import Iterable
from alembic import op
import sqlalchemy as sa
revision = "202607270001"
down_revision = "202607260005"
branch_labels = None
depends_on = None
AI_MEMORY_TABLE = "ai_memory_entries"
MARKET_WATCHLIST_TABLE = "market_watchlists"
APPROVAL_TABLE = "approval_requests"
AI_MEMORY_OWNER_UNIQUE = "uq_ai_memory_owner_fingerprint"
MARKET_WATCHLIST_OWNER_UNIQUE = "uq_market_watchlist_owner_symbol"
MARKET_WATCHLIST_LEGACY_UNIQUE = "uq_market_watchlist_actor_symbol"
def _inspector() -> sa.Inspector:
return sa.inspect(op.get_bind())
def _table_exists(table_name: str) -> bool:
return table_name in _inspector().get_table_names()
def _column_names(table_name: str) -> set[str]:
return {
str(column["name"])
for column in _inspector().get_columns(table_name)
}
def _add_missing_columns(
table_name: str,
columns: Iterable[sa.Column],
) -> None:
existing = _column_names(table_name)
for column in columns:
if column.name not in existing:
op.add_column(table_name, column)
def _index_by_name(table_name: str, index_name: str) -> dict | None:
return next(
(
index
for index in _inspector().get_indexes(table_name)
if index.get("name") == index_name
),
None,
)
def _ensure_index(
table_name: str,
index_name: str,
columns: tuple[str, ...],
*,
unique: bool = False,
) -> None:
existing = _index_by_name(table_name, index_name)
if existing is not None:
existing_columns = tuple(existing.get("column_names") or ())
if existing_columns != columns or bool(existing.get("unique")) != unique:
raise RuntimeError(
f"Existing index {index_name} does not match the reconciliation definition"
)
return
op.create_index(index_name, table_name, list(columns), unique=unique)
def _drop_index_if_present(table_name: str, index_name: str) -> None:
if _index_by_name(table_name, index_name) is not None:
op.drop_index(index_name, table_name=table_name)
def _unique_by_name(table_name: str, constraint_name: str) -> dict | None:
return next(
(
constraint
for constraint in _inspector().get_unique_constraints(table_name)
if constraint.get("name") == constraint_name
),
None,
)
def _drop_unique_if_present(table_name: str, constraint_name: str) -> None:
if _unique_by_name(table_name, constraint_name) is None:
return
with op.batch_alter_table(table_name) as batch_op:
batch_op.drop_constraint(constraint_name, type_="unique")
def _uses_nulls_not_distinct(constraint: dict) -> bool:
options = constraint.get("dialect_options") or {}
return bool(options.get("postgresql_nulls_not_distinct"))
def _ensure_owner_unique(
table_name: str,
constraint_name: str,
columns: tuple[str, ...],
) -> None:
existing = _unique_by_name(table_name, constraint_name)
dialect_name = op.get_bind().dialect.name
if existing is not None:
if tuple(existing.get("column_names") or ()) != columns:
raise RuntimeError(
f"Existing constraint {constraint_name} does not match the reconciliation definition"
)
if dialect_name != "postgresql" or _uses_nulls_not_distinct(existing):
return
with op.batch_alter_table(table_name) as batch_op:
batch_op.drop_constraint(constraint_name, type_="unique")
with op.batch_alter_table(table_name) as batch_op:
batch_op.create_unique_constraint(
constraint_name,
list(columns),
postgresql_nulls_not_distinct=True,
)
def _ensure_foreign_key(
table_name: str,
constraint_name: str,
local_columns: tuple[str, ...],
remote_table: str,
remote_columns: tuple[str, ...],
) -> None:
for foreign_key in _inspector().get_foreign_keys(table_name):
if (
tuple(foreign_key.get("constrained_columns") or ()) == local_columns
and foreign_key.get("referred_table") == remote_table
and tuple(foreign_key.get("referred_columns") or ()) == remote_columns
):
ondelete = str((foreign_key.get("options") or {}).get("ondelete") or "")
if ondelete.upper() != "CASCADE":
raise RuntimeError(
f"Existing foreign key on {table_name} does not use ON DELETE CASCADE"
)
return
with op.batch_alter_table(table_name) as batch_op:
batch_op.create_foreign_key(
constraint_name,
remote_table,
list(local_columns),
list(remote_columns),
ondelete="CASCADE",
)
def _alter_without_server_default(
table_name: str,
columns: tuple[tuple[str, sa.types.TypeEngine], ...],
) -> None:
with op.batch_alter_table(table_name) as batch_op:
for column_name, column_type in columns:
batch_op.alter_column(
column_name,
existing_type=column_type,
nullable=False,
server_default=None,
)
def _add_business_columns() -> None:
_add_missing_columns(
"attendance_records",
(
sa.Column(
"attendance_scope",
sa.String(length=32),
nullable=False,
server_default="company",
),
sa.Column("source_status", sa.String(length=64), nullable=True),
sa.Column("source_location_status", sa.String(length=64), nullable=True),
sa.Column("source_updated_at", sa.DateTime(), nullable=True),
sa.Column("last_seen_at", sa.DateTime(), nullable=True),
sa.Column(
"is_active",
sa.Boolean(),
nullable=False,
server_default=sa.true(),
),
),
)
_add_missing_columns(
"audit_logs",
(sa.Column("request_id", sa.String(length=64), nullable=True),),
)
_add_missing_columns(
"projects",
(
sa.Column("display_code", sa.String(length=64), nullable=True),
sa.Column("department_code", sa.String(length=64), nullable=True),
sa.Column("department_name", sa.String(length=128), nullable=True),
sa.Column("owner_employee_code", sa.String(length=64), nullable=True),
sa.Column("source_stage", sa.String(length=32), nullable=True),
sa.Column("source_stage_label", sa.String(length=128), nullable=True),
sa.Column(
"source_archived",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
sa.Column("source_created_at", sa.DateTime(), nullable=True),
sa.Column("source_updated_at", sa.DateTime(), nullable=True),
sa.Column("last_seen_at", sa.DateTime(), nullable=True),
sa.Column(
"is_active",
sa.Boolean(),
nullable=False,
server_default=sa.true(),
),
sa.Column(
"source_contract_amount",
sa.Numeric(precision=16, scale=2),
nullable=True,
),
sa.Column(
"source_project_investment_amount",
sa.Numeric(precision=16, scale=2),
nullable=True,
),
),
)
_add_missing_columns(
"risk_events",
(
sa.Column("assigned_to", sa.String(length=128), nullable=True),
sa.Column("resolved_at", sa.DateTime(), nullable=True),
sa.Column("closed_at", sa.DateTime(), nullable=True),
sa.Column("closed_reason", sa.Text(), nullable=True),
sa.Column("review_summary", sa.Text(), nullable=True),
),
)
_add_missing_columns(
"work_reports",
(
sa.Column("employee_code", sa.String(length=64), nullable=True),
sa.Column("external_id", sa.String(length=128), nullable=True),
sa.Column(
"is_late",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
sa.Column(
"is_draft",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
),
sa.Column("source_updated_at", sa.DateTime(), nullable=True),
sa.Column("last_seen_at", sa.DateTime(), nullable=True),
sa.Column(
"is_active",
sa.Boolean(),
nullable=False,
server_default=sa.true(),
),
),
)
_add_missing_columns(
"work_tasks",
(
sa.Column(
"source_system",
sa.String(length=64),
nullable=False,
server_default="internal",
),
sa.Column("external_id", sa.String(length=128), nullable=True),
sa.Column("employee_code", sa.String(length=64), nullable=True),
sa.Column("source_created_at", sa.DateTime(), nullable=True),
sa.Column("source_updated_at", sa.DateTime(), nullable=True),
sa.Column("last_seen_at", sa.DateTime(), nullable=True),
sa.Column(
"is_active",
sa.Boolean(),
nullable=False,
server_default=sa.true(),
),
),
)
op.execute(
sa.text(
"""
UPDATE attendance_records
SET attendance_scope = 'company'
WHERE attendance_scope IS NULL
"""
)
)
op.execute(
sa.text(
"""
UPDATE attendance_records
SET is_active = true
WHERE is_active IS NULL
"""
)
)
op.execute(
sa.text(
"""
UPDATE projects
SET source_archived = false
WHERE source_archived IS NULL
"""
)
)
op.execute(
sa.text(
"""
UPDATE projects
SET is_active = true
WHERE is_active IS NULL
"""
)
)
op.execute(
sa.text(
"""
UPDATE work_reports
SET is_late = false
WHERE is_late IS NULL
"""
)
)
op.execute(
sa.text(
"""
UPDATE work_reports
SET is_draft = false
WHERE is_draft IS NULL
"""
)
)
op.execute(
sa.text(
"""
UPDATE work_reports
SET is_active = true
WHERE is_active IS NULL
"""
)
)
op.execute(
sa.text(
"""
UPDATE work_tasks
SET source_system = 'internal'
WHERE source_system IS NULL
"""
)
)
op.execute(
sa.text(
"""
UPDATE work_tasks
SET is_active = true
WHERE is_active IS NULL
"""
)
)
_alter_without_server_default(
"attendance_records",
(
("attendance_scope", sa.String(length=32)),
("is_active", sa.Boolean()),
),
)
_alter_without_server_default(
"projects",
(
("source_archived", sa.Boolean()),
("is_active", sa.Boolean()),
),
)
_alter_without_server_default(
"work_reports",
(
("is_late", sa.Boolean()),
("is_draft", sa.Boolean()),
("is_active", sa.Boolean()),
),
)
_alter_without_server_default(
"work_tasks",
(
("source_system", sa.String(length=64)),
("is_active", sa.Boolean()),
),
)
def _ensure_business_indexes() -> None:
definitions = {
"attendance_records": (
"attendance_scope",
"is_active",
"last_seen_at",
"source_status",
"source_updated_at",
),
"audit_logs": ("request_id",),
"projects": (
"department_code",
"department_name",
"display_code",
"is_active",
"last_seen_at",
"owner_employee_code",
"source_archived",
"source_stage",
"source_updated_at",
),
"risk_events": ("assigned_to",),
"work_reports": (
"employee_code",
"external_id",
"is_active",
"is_draft",
"is_late",
"last_seen_at",
"source_updated_at",
),
"work_tasks": (
"employee_code",
"external_id",
"is_active",
"last_seen_at",
"source_updated_at",
),
}
for table_name, columns in definitions.items():
for column_name in columns:
_ensure_index(
table_name,
f"ix_{table_name}_{column_name}",
(column_name,),
)
def _reconcile_ai_memory() -> None:
_add_missing_columns(
AI_MEMORY_TABLE,
(
sa.Column("owner_id", sa.Integer(), nullable=True),
sa.Column(
"kind",
sa.String(length=32),
nullable=False,
server_default="memory",
),
),
)
op.execute(
sa.text(
"""
UPDATE ai_memory_entries
SET
owner_id = NULL,
kind = 'company_rule',
source = 'legacy_company',
status = 'active'
WHERE source = 'user_rule'
"""
)
)
op.execute(
sa.text(
"""
UPDATE ai_memory_entries
SET kind = 'memory', status = 'archived'
WHERE owner_id IS NULL AND source IN ('auto', 'hermes')
"""
)
)
op.execute(
sa.text(
"""
UPDATE ai_memory_entries
SET kind = 'memory'
WHERE kind IS NULL
"""
)
)
_alter_without_server_default(
AI_MEMORY_TABLE,
(("kind", sa.String(length=32)),),
)
fingerprint_index = _index_by_name(
AI_MEMORY_TABLE,
"ix_ai_memory_entries_fingerprint",
)
if fingerprint_index is not None and bool(fingerprint_index.get("unique")):
op.drop_index(
"ix_ai_memory_entries_fingerprint",
table_name=AI_MEMORY_TABLE,
)
_ensure_index(
AI_MEMORY_TABLE,
"ix_ai_memory_entries_fingerprint",
("fingerprint",),
)
_ensure_index(
AI_MEMORY_TABLE,
"ix_ai_memory_entries_kind",
("kind",),
)
_ensure_index(
AI_MEMORY_TABLE,
"ix_ai_memory_entries_owner_id",
("owner_id",),
)
_ensure_foreign_key(
AI_MEMORY_TABLE,
"fk_ai_memory_entries_owner_id",
("owner_id",),
"feishu_users",
("id",),
)
_ensure_owner_unique(
AI_MEMORY_TABLE,
AI_MEMORY_OWNER_UNIQUE,
("owner_id", "fingerprint"),
)
def _reconcile_market_watchlists() -> None:
_add_missing_columns(
MARKET_WATCHLIST_TABLE,
(sa.Column("owner_id", sa.Integer(), nullable=True),),
)
_drop_unique_if_present(
MARKET_WATCHLIST_TABLE,
MARKET_WATCHLIST_LEGACY_UNIQUE,
)
_ensure_index(
MARKET_WATCHLIST_TABLE,
"ix_market_watchlists_owner_id",
("owner_id",),
)
_ensure_foreign_key(
MARKET_WATCHLIST_TABLE,
"fk_market_watchlists_owner_id",
("owner_id",),
"feishu_users",
("id",),
)
_ensure_owner_unique(
MARKET_WATCHLIST_TABLE,
MARKET_WATCHLIST_OWNER_UNIQUE,
("owner_id", "symbol"),
)
def _create_feishu_app_tickets_if_missing() -> None:
if not _table_exists("feishu_app_tickets"):
op.create_table(
"feishu_app_tickets",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("app_id", sa.String(length=128), nullable=False),
sa.Column("app_ticket", sa.Text(), nullable=False),
sa.Column("received_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
_ensure_index(
"feishu_app_tickets",
"ix_feishu_app_tickets_app_id",
("app_id",),
unique=True,
)
_ensure_index(
"feishu_app_tickets",
"ix_feishu_app_tickets_received_at",
("received_at",),
)
def _create_admin_tombstones_if_missing() -> None:
if not _table_exists("feishu_admin_bootstrap_tombstones"):
op.create_table(
"feishu_admin_bootstrap_tombstones",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("identity_hash", sa.String(length=64), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
_ensure_index(
"feishu_admin_bootstrap_tombstones",
"ix_feishu_admin_bootstrap_tombstones_created_at",
("created_at",),
)
_ensure_index(
"feishu_admin_bootstrap_tombstones",
"ix_feishu_admin_bootstrap_tombstones_identity_hash",
("identity_hash",),
unique=True,
)
def _drop_empty_approval_table() -> None:
if not _table_exists(APPROVAL_TABLE):
return
count = int(
op.get_bind().execute(
sa.text("SELECT COUNT(*) FROM approval_requests")
).scalar_one()
)
if count:
raise RuntimeError(
"Refusing to remove approval_requests because it contains rows"
)
for table_name in _inspector().get_table_names():
for foreign_key in _inspector().get_foreign_keys(table_name):
if foreign_key.get("referred_table") == APPROVAL_TABLE:
raise RuntimeError(
"Refusing to remove approval_requests because a foreign key depends on it"
)
op.drop_table(APPROVAL_TABLE)
def upgrade() -> None:
_add_business_columns()
_ensure_business_indexes()
_reconcile_ai_memory()
_reconcile_market_watchlists()
_create_feishu_app_tickets_if_missing()
_create_admin_tombstones_if_missing()
_drop_empty_approval_table()
def downgrade() -> None:
raise RuntimeError(
"Revision 202607270001 is irreversible: it classifies legacy data, "
"adds ownership relationships, and removes the obsolete approval table. "
"Restore a verified database backup instead of downgrading."
)

View File

@@ -0,0 +1,251 @@
"""Upgrade Feishu event receipts to a durable inbound inbox.
Revision ID: 202607270002
Revises: 202607270001
Create Date: 2026-07-27
"""
import re
from hashlib import sha256
from typing import Any
from alembic import op
import sqlalchemy as sa
revision = "202607270002"
down_revision = "202607270001"
branch_labels = None
depends_on = None
TABLE_NAME = "feishu_event_receipts"
INDEX_COLUMNS = (
"event_type",
"status",
"next_attempt_at",
"locked_until",
"locked_by",
"processed_at",
"reply_status",
"reply_next_attempt_at",
"reply_locked_until",
"reply_locked_by",
"reply_sent_at",
)
IDENTIFIER_DIGEST_PATTERN = re.compile(r"sha256-[0-9a-f]{64}\Z")
IDENTIFIER_DIGEST_PREFIX = "company-ai-platform:feishu"
def _identifier_digest(value: Any, domain: str) -> str | None:
text = str(value or "").strip()
if not text:
return None
if IDENTIFIER_DIGEST_PATTERN.fullmatch(text):
return text
digest = sha256(
f"{IDENTIFIER_DIGEST_PREFIX}:{domain}:v1\0{text}".encode("utf-8")
).hexdigest()
return f"sha256-{digest}"
def _migrate_historical_identifiers() -> None:
"""Replace legacy raw receipt identifiers without hashing them twice.
A strict current-format digest is the only durable marker available in the
legacy schema, so matching values are treated as already migrated. A raw
identifier that happens to match that format is therefore intentionally
indistinguishable and remains unchanged.
"""
bind = op.get_bind()
receipts = sa.table(
TABLE_NAME,
sa.column("id", sa.Integer()),
sa.column("event_key", sa.String()),
sa.column("event_id", sa.String()),
sa.column("message_id", sa.String()),
)
rows = bind.execute(
sa.select(
receipts.c.id,
receipts.c.event_key,
receipts.c.event_id,
receipts.c.message_id,
).order_by(receipts.c.id)
).mappings()
updates: list[tuple[int, dict[str, str | None]]] = []
target_event_keys: dict[str, int] = {}
for row in rows:
row_id = int(row["id"])
values = {
"event_key": _identifier_digest(row["event_key"], "event-key"),
"event_id": _identifier_digest(row["event_id"], "event-id"),
"message_id": _identifier_digest(row["message_id"], "message-id"),
}
target_event_key = values["event_key"]
if target_event_key is None:
raise RuntimeError(
f"Cannot migrate blank Feishu receipt event_key at row {row_id}"
)
conflicting_row_id = target_event_keys.setdefault(target_event_key, row_id)
if conflicting_row_id != row_id:
raise RuntimeError(
"Cannot migrate colliding Feishu receipt event_key values at "
f"rows {conflicting_row_id} and {row_id}"
)
if any(values[column] != row[column] for column in values):
updates.append((row_id, values))
for row_id, values in updates:
bind.execute(
sa.update(receipts).where(receipts.c.id == row_id).values(**values)
)
def upgrade() -> None:
_migrate_historical_identifiers()
op.add_column(
TABLE_NAME,
sa.Column("event_type", sa.String(length=128), nullable=True),
)
op.add_column(TABLE_NAME, sa.Column("payload", sa.JSON(), nullable=True))
op.add_column(
TABLE_NAME,
sa.Column(
"auto_reply",
sa.Boolean(),
server_default=sa.true(),
nullable=False,
),
)
op.add_column(
TABLE_NAME,
sa.Column(
"status",
sa.String(length=32),
server_default="pending",
nullable=False,
),
)
op.add_column(
TABLE_NAME,
sa.Column(
"attempt_count",
sa.Integer(),
server_default="0",
nullable=False,
),
)
op.add_column(
TABLE_NAME,
sa.Column(
"max_attempts",
sa.Integer(),
server_default="4",
nullable=False,
),
)
op.add_column(TABLE_NAME, sa.Column("last_error", sa.Text(), nullable=True))
op.add_column(
TABLE_NAME,
sa.Column("next_attempt_at", sa.DateTime(), nullable=True),
)
op.add_column(
TABLE_NAME,
sa.Column("locked_until", sa.DateTime(), nullable=True),
)
op.add_column(
TABLE_NAME,
sa.Column("locked_by", sa.String(length=128), nullable=True),
)
op.add_column(
TABLE_NAME,
sa.Column("processed_at", sa.DateTime(), nullable=True),
)
op.add_column(TABLE_NAME, sa.Column("reply_payload", sa.JSON(), nullable=True))
op.add_column(
TABLE_NAME,
sa.Column("reply_status", sa.String(length=32), nullable=True),
)
op.add_column(
TABLE_NAME,
sa.Column(
"reply_attempt_count",
sa.Integer(),
server_default="0",
nullable=False,
),
)
op.add_column(
TABLE_NAME,
sa.Column("reply_last_error", sa.Text(), nullable=True),
)
op.add_column(
TABLE_NAME,
sa.Column("reply_next_attempt_at", sa.DateTime(), nullable=True),
)
op.add_column(
TABLE_NAME,
sa.Column("reply_locked_until", sa.DateTime(), nullable=True),
)
op.add_column(
TABLE_NAME,
sa.Column("reply_locked_by", sa.String(length=128), nullable=True),
)
op.add_column(
TABLE_NAME,
sa.Column("reply_sent_at", sa.DateTime(), nullable=True),
)
# Historical receipt-only rows have no replayable payload. Treat them as
# completed so deploying the inbox cannot execute old commands.
op.execute(
sa.text(
f"""
UPDATE {TABLE_NAME}
SET status = 'succeeded',
attempt_count = 1,
processed_at = received_at
"""
)
)
for column in INDEX_COLUMNS:
op.create_index(
op.f(f"ix_{TABLE_NAME}_{column}"),
TABLE_NAME,
[column],
unique=False,
)
def downgrade() -> None:
# Identifier digests are intentionally irreversible and remain in place.
for column in reversed(INDEX_COLUMNS):
op.drop_index(
op.f(f"ix_{TABLE_NAME}_{column}"),
table_name=TABLE_NAME,
)
for column in (
"processed_at",
"locked_by",
"locked_until",
"next_attempt_at",
"last_error",
"max_attempts",
"attempt_count",
"status",
"auto_reply",
"payload",
"event_type",
"reply_sent_at",
"reply_locked_by",
"reply_locked_until",
"reply_next_attempt_at",
"reply_last_error",
"reply_attempt_count",
"reply_status",
"reply_payload",
):
op.drop_column(TABLE_NAME, column)