diff --git a/SKILL.md b/SKILL.md index a5767a1..4a86950 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: 账号与凭据管理 description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA 账号、API Key/Token;支持 9 种登录策略(账密自动、扫码、2FA、SSO 等);提供加密本地存储(Fernet);提供 rpa_helpers 库供业务 skill 同进程调用 ensure_logged_in 完成多平台自动登录。涵盖物流、LLM、内容、开发、API 五类平台。" -version: 2.0.11 +version: 2.0.12 author: 深圳匠厂科技有限公司 metadata: openclaw: diff --git a/scripts/db/connection.py b/scripts/db/connection.py index f4bdbf5..4749ea1 100644 --- a/scripts/db/connection.py +++ b/scripts/db/connection.py @@ -14,6 +14,7 @@ import sqlite3 import time from typing import Optional +from db.display_metadata import init_display_metadata from db.schema import SCHEMA_VERSION, ensure_schema from util.constants import SKILL_SLUG from util.logging_config import get_skill_logger @@ -85,6 +86,7 @@ def init_db() -> None: ensure_schema(conn) cur = conn.cursor() + init_display_metadata(cur) cur.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") conn.commit() diff --git a/scripts/db/display_metadata.py b/scripts/db/display_metadata.py new file mode 100644 index 0000000..657f039 --- /dev/null +++ b/scripts/db/display_metadata.py @@ -0,0 +1,421 @@ +"""匠厂数据管理展示元数据(_jiangchang_tables / _jiangchang_columns)。 + +SQLite 无原生表/字段 COMMENT;中文展示名称通过这两张元数据表提供给宿主「数据管理」页面。 +字段在界面上的顺序由 CREATE TABLE / PRAGMA table_info(cid) 决定,**不**使用 display_order 排序。 +""" + +from __future__ import annotations + +from typing import Iterable + +INTERNAL_METADATA_TABLES = frozenset({"_jiangchang_tables", "_jiangchang_columns"}) +LEGACY_METADATA_TABLES = frozenset({"_schema_meta"}) +METADATA_TABLES = INTERNAL_METADATA_TABLES | LEGACY_METADATA_TABLES + +PLATFORMS_TABLE = "platforms" +ACCOUNTS_TABLE = "accounts" +CREDENTIALS_TABLE = "credentials" +ACCOUNT_LEASES_TABLE = "account_leases" + +DATETIME_UNIX_SECONDS = "datetime_unix_seconds" +STANDARD_TIMESTAMP_COLUMNS = frozenset({"created_at", "updated_at"}) + +USER_BUSINESS_TABLES = ( + PLATFORMS_TABLE, + ACCOUNTS_TABLE, + CREDENTIALS_TABLE, + ACCOUNT_LEASES_TABLE, +) + +_JIANGCHANG_TABLES_DDL = """ +CREATE TABLE IF NOT EXISTS _jiangchang_tables ( + table_name TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + description TEXT, + sort_order INTEGER, + visible INTEGER NOT NULL DEFAULT 1, + readonly INTEGER NOT NULL DEFAULT 0 +) +""" + +_JIANGCHANG_COLUMNS_DDL = """ +CREATE TABLE IF NOT EXISTS _jiangchang_columns ( + table_name TEXT NOT NULL, + column_name TEXT NOT NULL, + display_name TEXT NOT NULL, + description TEXT, + display_order INTEGER, + visible INTEGER NOT NULL DEFAULT 1, + searchable INTEGER NOT NULL DEFAULT 1, + editable INTEGER NOT NULL DEFAULT 1, + display_type TEXT, + options_json TEXT, + PRIMARY KEY (table_name, column_name) +) +""" + + +def _col( + column_name: str, + display_name: str, + *, + description: str | None = None, + visible: int = 1, + searchable: int = 1, + editable: int = 1, + display_type: str | None = None, + options_json: str | None = None, +) -> dict[str, object]: + return { + "column_name": column_name, + "display_name": display_name, + "description": description, + "visible": visible, + "searchable": searchable, + "editable": editable, + "display_type": display_type, + "options_json": options_json, + } + + +def _ts(column_name: str, display_name: str, description: str) -> dict[str, object]: + return _col( + column_name, + display_name, + description=description, + searchable=0, + editable=0, + display_type=DATETIME_UNIX_SECONDS, + ) + + +PLATFORMS_TABLE_METADATA = { + "table_name": PLATFORMS_TABLE, + "display_name": "平台信息", + "description": "已注册平台(物流、LLM、内容等)及默认登录策略", + "sort_order": 1, + "visible": 1, + "readonly": 0, +} + +PLATFORMS_COLUMN_METADATA: tuple[dict[str, object], ...] = ( + _col("platform_key", "平台键", description="平台唯一标识(英文 kebab-case)", editable=0), + _col("display_name", "展示名称", description="平台在界面上的显示名称"), + _col("domain", "业务域", description="logistics / llm / content / dev / sim 等"), + _col("provider_code", "供应商代码", description="供应商或平台代码,可空"), + _col("default_url", "默认链接", description="平台默认入口 URL"), + _col("aliases_json", "别名列表", description="平台别名 JSON 数组", editable=0), + _col("capabilities_json", "能力声明", description='能力 JSON,如 {"web":true,"api_key":false}', editable=0), + _col("enabled", "是否启用", description="1=启用,0=停用"), + _col("default_auth_strategy", "默认登录策略", description="新建账号时的默认 auth_strategy"), + _ts("created_at", "创建时间", "记录创建时间(Unix 秒级时间戳)"), + _ts("updated_at", "更新时间", "记录最后更新时间(Unix 秒级时间戳)"), +) + +ACCOUNTS_TABLE_METADATA = { + "table_name": ACCOUNTS_TABLE, + "display_name": "账号信息", + "description": "网页/RPA 账号身份与浏览器 Profile 路径", + "sort_order": 2, + "visible": 1, + "readonly": 0, +} + +ACCOUNTS_COLUMN_METADATA: tuple[dict[str, object], ...] = ( + _col("id", "编号", description="账号唯一编号", editable=0), + _col("platform_key", "平台", description="所属平台键"), + _col("account_label", "账号名称", description="人可读的账号名称"), + _col("login_id", "登录账号", description="手机号、邮箱、用户名等登录标识"), + _col("login_id_type", "登录标识类型", description="phone / email / username / unknown 等"), + _col("tenant_id", "租户编号", description="租户或客户标识,可空"), + _col("environment", "环境", description="production / simulator / staging 等"), + _col("role", "角色", description="booking / tracking / api / default 等"), + _col("provider_code", "供应商代码", description="冗余供应商码,便于查询"), + _col("profile_dir", "浏览器配置目录", description="Playwright 持久化 Profile 路径", editable=0), + _col("url", "入口链接", description="账号默认入口 URL"), + _col("status", "状态", description="active / disabled / needs_review 等"), + _col("session_status", "会话状态", description="unknown / likely_valid / needs_login 等"), + _ts("last_used_at", "最后使用时间", "最近一次被业务技能使用的时间"), + _ts("last_login_check_at", "最后登录检查时间", "最近一次登录状态检查时间"), + _col("last_error_code", "最近错误码", description="最近一次错误代码", editable=0), + _col("last_error_message", "最近错误信息", description="最近一次错误说明", editable=0), + _col("extra_json", "扩展信息", description="扩展 JSON 配置", editable=0), + _col("auth_strategy", "登录方式", description="九种登录策略之一"), + _col("session_persistent", "持久会话", description="是否需要持久化浏览器会话(0/1)"), + _col("device_fingerprint", "设备指纹", description="device_bound 策略专用,可空"), + _ts("created_at", "创建时间", "记录创建时间(Unix 秒级时间戳)"), + _ts("updated_at", "更新时间", "记录最后更新时间(Unix 秒级时间戳)"), +) + +CREDENTIALS_TABLE_METADATA = { + "table_name": CREDENTIALS_TABLE, + "display_name": "凭证信息", + "description": "API Key、Token、加密密码等凭据元数据(不含明文)", + "sort_order": 3, + "visible": 1, + "readonly": 0, +} + +CREDENTIALS_COLUMN_METADATA: tuple[dict[str, object], ...] = ( + _col("id", "编号", description="凭据唯一编号", editable=0), + _col("account_id", "账号编号", description="关联的 accounts.id,可空"), + _col("platform_key", "平台", description="所属平台键"), + _col("credential_label", "凭据名称", description="人可读的凭据名称"), + _col("credential_type", "凭据类型", description="api_key / password / bearer_token 等"), + _col("secret_storage", "密钥存储方式", description="none / env / windows_credential / local_encrypted", editable=0), + _col("secret_ref", "密钥引用", description="环境变量名或凭据管理器 target", editable=0, searchable=0), + _col("secret_mask", "密钥掩码", description="打码后的 secret 展示", editable=0, searchable=0), + _col( + "secret_ciphertext", + "密钥密文", + description="local_encrypted 时的 Fernet 密文,不可手工编辑", + editable=0, + searchable=0, + ), + _ts("expires_at", "过期时间", "凭据过期时间,可空"), + _col("status", "状态", description="active / disabled / expired 等"), + _ts("last_used_at", "最后使用时间", "最近一次使用时间"), + _col("extra_json", "扩展信息", description="扩展 JSON 配置", editable=0), + _ts("created_at", "创建时间", "记录创建时间(Unix 秒级时间戳)"), + _ts("updated_at", "更新时间", "记录最后更新时间(Unix 秒级时间戳)"), +) + +ACCOUNT_LEASES_TABLE_METADATA = { + "table_name": ACCOUNT_LEASES_TABLE, + "display_name": "账号租约", + "description": "RPA 并发租约,防止多任务同时使用同一浏览器 Profile", + "sort_order": 4, + "visible": 1, + "readonly": 1, +} + +ACCOUNT_LEASES_COLUMN_METADATA: tuple[dict[str, object], ...] = ( + _col("id", "编号", description="租约唯一编号", editable=0), + _col("account_id", "账号编号", description="关联的 accounts.id", editable=0), + _col("lease_token", "租约令牌", description="用于 release 的随机 token", editable=0, searchable=1), + _col("holder", "持有者", description="持有租约的业务技能或任务标识", editable=0), + _col("purpose", "用途", description="租约用途说明", editable=0), + _ts("expires_at", "过期时间", "租约过期时间"), + _ts("heartbeat_at", "心跳时间", "最近一次心跳时间"), + _ts("released_at", "释放时间", "主动释放时间,可空"), + _col("status", "状态", description="active / released / expired", editable=0), + _ts("created_at", "创建时间", "记录创建时间(Unix 秒级时间戳)"), + _ts("updated_at", "更新时间", "记录最后更新时间(Unix 秒级时间戳)"), +) + +_TABLE_SEEDS: tuple[tuple[dict[str, object], tuple[dict[str, object], ...]], ...] = ( + (PLATFORMS_TABLE_METADATA, PLATFORMS_COLUMN_METADATA), + (ACCOUNTS_TABLE_METADATA, ACCOUNTS_COLUMN_METADATA), + (CREDENTIALS_TABLE_METADATA, CREDENTIALS_COLUMN_METADATA), + (ACCOUNT_LEASES_TABLE_METADATA, ACCOUNT_LEASES_COLUMN_METADATA), +) + + +def init_display_metadata_tables(cursor) -> None: + cursor.execute(_JIANGCHANG_TABLES_DDL) + cursor.execute(_JIANGCHANG_COLUMNS_DDL) + + +def upsert_table_metadata( + cursor, + *, + table_name: str, + display_name: str, + description: str | None = None, + sort_order: int | None = None, + visible: int = 1, + readonly: int = 0, +) -> None: + cursor.execute( + """ + INSERT INTO _jiangchang_tables ( + table_name, display_name, description, sort_order, visible, readonly + ) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(table_name) DO UPDATE SET + display_name = excluded.display_name, + description = excluded.description, + sort_order = excluded.sort_order, + visible = excluded.visible, + readonly = excluded.readonly + """, + (table_name, display_name, description, sort_order, visible, readonly), + ) + + +def upsert_column_metadata( + cursor, + *, + table_name: str, + column_name: str, + display_name: str, + description: str | None = None, + visible: int = 1, + searchable: int = 1, + editable: int = 1, + display_type: str | None = None, + options_json: str | None = None, +) -> None: + cursor.execute( + """ + INSERT INTO _jiangchang_columns ( + table_name, column_name, display_name, description, + display_order, visible, searchable, editable, display_type, options_json + ) + VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?) + ON CONFLICT(table_name, column_name) DO UPDATE SET + display_name = excluded.display_name, + description = excluded.description, + display_order = NULL, + visible = excluded.visible, + searchable = excluded.searchable, + editable = excluded.editable, + display_type = excluded.display_type, + options_json = excluded.options_json + """, + ( + table_name, + column_name, + display_name, + description, + visible, + searchable, + editable, + display_type, + options_json, + ), + ) + + +def seed_column_metadata_batch( + cursor, + *, + table_name: str, + columns: Iterable[dict[str, object]], + valid_column_names: Iterable[str] | None = None, +) -> None: + for column in columns: + upsert_column_metadata( + cursor, + table_name=table_name, + column_name=str(column["column_name"]), + display_name=str(column["display_name"]), + description=str(column["description"]) if column.get("description") else None, + visible=int(column.get("visible", 1)), + searchable=int(column.get("searchable", 1)), + editable=int(column.get("editable", 1)), + display_type=str(column["display_type"]) if column.get("display_type") else None, + options_json=str(column["options_json"]) if column.get("options_json") else None, + ) + if valid_column_names is not None: + prune_stale_column_metadata(cursor, table_name, valid_column_names) + + +def prune_stale_column_metadata(cursor, table_name: str, valid_column_names: Iterable[str]) -> None: + valid = set(valid_column_names) + cursor.execute( + "SELECT column_name FROM _jiangchang_columns WHERE table_name = ?", + (table_name,), + ) + for (column_name,) in cursor.fetchall(): + if str(column_name) not in valid: + cursor.execute( + "DELETE FROM _jiangchang_columns WHERE table_name = ? AND column_name = ?", + (table_name, column_name), + ) + + +def get_table_readonly(cursor, table_name: str) -> int | None: + cursor.execute( + "SELECT readonly FROM _jiangchang_tables WHERE table_name = ?", + (table_name,), + ) + row = cursor.fetchone() + if not row: + return None + return int(row[0] or 0) + + +def get_column_editable(cursor, table_name: str, column_name: str) -> int | None: + cursor.execute( + """ + SELECT editable FROM _jiangchang_columns + WHERE table_name = ? AND column_name = ? + """, + (table_name, column_name), + ) + row = cursor.fetchone() + if not row: + return None + return int(row[0] or 0) + + +def list_editable_business_columns(cursor, table_name: str) -> list[str]: + physical = list_physical_column_names(cursor, table_name) + cursor.execute( + """ + SELECT column_name, editable + FROM _jiangchang_columns + WHERE table_name = ? + """, + (table_name,), + ) + editable_set = { + str(column_name) + for column_name, editable in cursor.fetchall() + if editable is not None and int(editable) == 1 + } + return [name for name in physical if name in editable_set] + + +def table_allows_manual_create(cursor, table_name: str) -> bool: + readonly = get_table_readonly(cursor, table_name) + if readonly is None or int(readonly) != 0: + return False + return bool(list_editable_business_columns(cursor, table_name)) + + +def seed_business_table_display_metadata(cursor, table_meta: dict[str, object], columns: tuple[dict[str, object], ...]) -> None: + table_name = str(table_meta["table_name"]) + upsert_table_metadata( + cursor, + table_name=table_name, + display_name=str(table_meta["display_name"]), + description=str(table_meta["description"]) if table_meta.get("description") else None, + sort_order=int(table_meta["sort_order"]) if table_meta.get("sort_order") is not None else None, + visible=int(table_meta.get("visible", 1)), + readonly=int(table_meta.get("readonly", 0)), + ) + seed_column_metadata_batch( + cursor, + table_name=table_name, + columns=columns, + valid_column_names=[str(column["column_name"]) for column in columns], + ) + + +def init_display_metadata(cursor) -> None: + init_display_metadata_tables(cursor) + for table_meta, columns in _TABLE_SEEDS: + seed_business_table_display_metadata(cursor, table_meta, columns) + + +def iter_user_tables(cursor) -> Iterable[str]: + cursor.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name" + ) + for (name,) in cursor.fetchall(): + if name in METADATA_TABLES: + continue + yield str(name) + + +def _quote_identifier(name: str) -> str: + return '"' + name.replace('"', '""') + '"' + + +def list_physical_column_names(cursor, table_name: str) -> list[str]: + cursor.execute(f"PRAGMA table_info({_quote_identifier(table_name)})") + rows = cursor.fetchall() + rows.sort(key=lambda row: int(row[0])) + return [str(row[1]) for row in rows] diff --git a/tests/test_display_metadata.py b/tests/test_display_metadata.py new file mode 100644 index 0000000..53f7a14 --- /dev/null +++ b/tests/test_display_metadata.py @@ -0,0 +1,263 @@ +# -*- coding: utf-8 -*- +"""数据管理展示元数据:幂等初始化、中文名称、敏感字段权限。""" +from __future__ import annotations + +import re + +import pytest + +from db.display_metadata import ( + ACCOUNT_LEASES_TABLE, + ACCOUNTS_TABLE, + CREDENTIALS_TABLE, + PLATFORMS_TABLE, + USER_BUSINESS_TABLES, + get_table_readonly, + init_display_metadata_tables, + list_physical_column_names, + table_allows_manual_create, + upsert_column_metadata, + upsert_table_metadata, +) + + +def _has_cjk(text: str) -> bool: + return bool(re.search(r"[\u4e00-\u9fff]", text or "")) + + +@pytest.fixture +def metadata_db(clean_db): + """clean_db already ran init_db(); return connection for metadata assertions.""" + yield clean_db + + +def test_init_db_metadata_idempotent(metadata_db): + from db.connection import get_conn, init_db + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM _jiangchang_tables") + count_after_second = int(cur.fetchone()[0]) + assert count_after_second == len(USER_BUSINESS_TABLES) + finally: + conn.close() + + +@pytest.mark.parametrize( + "table_name,expected_display", + [ + (PLATFORMS_TABLE, "平台信息"), + (ACCOUNTS_TABLE, "账号信息"), + (CREDENTIALS_TABLE, "凭证信息"), + (ACCOUNT_LEASES_TABLE, "账号租约"), + ], +) +def test_table_chinese_display_names(metadata_db, table_name, expected_display): + cur = metadata_db.cursor() + cur.execute( + "SELECT display_name FROM _jiangchang_tables WHERE table_name = ?", + (table_name,), + ) + row = cur.fetchone() + assert row is not None + assert row[0] == expected_display + + +def test_all_business_columns_have_chinese_display_names(metadata_db): + cur = metadata_db.cursor() + for table_name in USER_BUSINESS_TABLES: + physical = set(list_physical_column_names(cur, table_name)) + cur.execute( + """ + SELECT column_name, display_name + FROM _jiangchang_columns + WHERE table_name = ? + """, + (table_name,), + ) + meta_rows = cur.fetchall() + assert len(meta_rows) == len(physical), f"{table_name} 元数据字段数与物理表不一致" + for column_name, display_name in meta_rows: + assert column_name in physical + assert _has_cjk(str(display_name)), f"{table_name}.{column_name} 缺少中文 display_name" + + +def test_sensitive_credential_fields_not_editable(metadata_db): + cur = metadata_db.cursor() + sensitive = ("secret_ref", "secret_mask", "secret_ciphertext", "secret_storage") + for column_name in sensitive: + cur.execute( + """ + SELECT editable, searchable + FROM _jiangchang_columns + WHERE table_name = ? AND column_name = ? + """, + (CREDENTIALS_TABLE, column_name), + ) + editable, searchable = cur.fetchone() + assert int(editable) == 0, column_name + if column_name in ("secret_ref", "secret_ciphertext"): + assert int(searchable) == 0, column_name + + +def test_lease_token_not_editable(metadata_db): + cur = metadata_db.cursor() + cur.execute( + """ + SELECT editable FROM _jiangchang_columns + WHERE table_name = ? AND column_name = 'lease_token' + """, + (ACCOUNT_LEASES_TABLE,), + ) + assert int(cur.fetchone()[0]) == 0 + + +def test_account_leases_readonly(metadata_db): + cur = metadata_db.cursor() + assert int(get_table_readonly(cur, ACCOUNT_LEASES_TABLE)) == 1 + assert table_allows_manual_create(cur, ACCOUNT_LEASES_TABLE) is False + + +def test_platforms_key_not_editable(metadata_db): + cur = metadata_db.cursor() + cur.execute( + """ + SELECT editable FROM _jiangchang_columns + WHERE table_name = ? AND column_name = 'platform_key' + """, + (PLATFORMS_TABLE,), + ) + assert int(cur.fetchone()[0]) == 0 + + +def test_profile_dir_not_editable(metadata_db): + cur = metadata_db.cursor() + cur.execute( + """ + SELECT editable FROM _jiangchang_columns + WHERE table_name = ? AND column_name = 'profile_dir' + """, + (ACCOUNTS_TABLE,), + ) + assert int(cur.fetchone()[0]) == 0 + + +def test_timestamp_columns_use_datetime_display_type(metadata_db): + cur = metadata_db.cursor() + for table_name in USER_BUSINESS_TABLES: + cur.execute( + """ + SELECT column_name, display_type, editable + FROM _jiangchang_columns + WHERE table_name = ? AND column_name IN ('created_at', 'updated_at') + """, + (table_name,), + ) + for column_name, display_type, editable in cur.fetchall(): + assert display_type == "datetime_unix_seconds", f"{table_name}.{column_name}" + assert int(editable) == 0, f"{table_name}.{column_name}" + + +def test_metadata_tables_hidden_from_user_tables(metadata_db): + from db.display_metadata import iter_user_tables + + tables = list(iter_user_tables(metadata_db.cursor())) + assert "_jiangchang_tables" not in tables + assert "_jiangchang_columns" not in tables + assert "_schema_meta" not in tables + for name in USER_BUSINESS_TABLES: + assert name in tables + + +def test_reinit_clears_stale_column_metadata(metadata_db): + from db.connection import get_conn, init_db + + conn = get_conn() + try: + conn.execute( + """ + INSERT INTO _jiangchang_columns + (table_name, column_name, display_name, visible, searchable, editable) + VALUES ('accounts', 'stale_field', '陈旧字段', 1, 1, 0) + """ + ) + conn.commit() + finally: + conn.close() + + init_db() + conn = get_conn() + try: + cur = conn.cursor() + cur.execute( + """ + SELECT COUNT(*) FROM _jiangchang_columns + WHERE table_name = 'accounts' AND column_name = 'stale_field' + """ + ) + assert int(cur.fetchone()[0]) == 0 + finally: + conn.close() + + +def test_missing_column_metadata_detected_in_memory_db(): + import sqlite3 + + from db.display_metadata import init_display_metadata, list_physical_column_names + + conn = sqlite3.connect(":memory:") + try: + conn.executescript( + """ + CREATE TABLE platforms ( + platform_key TEXT PRIMARY KEY, + display_name TEXT NOT NULL + ); + """ + ) + init_display_metadata_tables(conn.cursor()) + upsert_table_metadata( + conn.cursor(), + table_name="platforms", + display_name="平台信息", + ) + upsert_column_metadata( + conn.cursor(), + table_name="platforms", + column_name="platform_key", + display_name="平台键", + editable=0, + ) + conn.commit() + + physical = list_physical_column_names(conn.cursor(), "platforms") + assert "display_name" in physical + cur = conn.cursor() + cur.execute( + "SELECT COUNT(*) FROM _jiangchang_columns WHERE table_name = 'platforms'" + ) + assert int(cur.fetchone()[0]) < len(physical) + finally: + conn.close() + + # Full seed should cover all physical columns + conn = sqlite3.connect(":memory:") + try: + from db.schema import ensure_schema + + ensure_schema(conn) + init_display_metadata(conn.cursor()) + conn.commit() + for table_name in USER_BUSINESS_TABLES: + physical = set(list_physical_column_names(conn.cursor(), table_name)) + cur = conn.cursor() + cur.execute( + "SELECT column_name FROM _jiangchang_columns WHERE table_name = ?", + (table_name,), + ) + meta_cols = {row[0] for row in cur.fetchall()} + assert meta_cols == physical, table_name + finally: + conn.close()