随着数据驱动决策成为企业运营的核心,大规模数据导入的可靠性和效率变得日益重要。Snowflake作为领先的云数据仓库平台,其COPY INTO命令是从S3等云存储导入CSV文件的主要工具。然而,在实际生产环境中,数据格式不一致、类型不兼容、编码问题等因素常常导致导入失败或数据质量下降。本文系统梳理了COPY INTO导入CSV文件时可能遇到的主要问题,提出了一套基于Python Pandas的自动化验证方案,并给出了可落地的实施建议,旨在帮助数据工程师在导入前主动发现问题、减少线上故障、提升数据管道的健壮性。
一、引言
Snowflake的COPY INTO命令是加载大规模数据的核心工具,支持从外部暂存区(如S3、Azure Blob、Google Cloud Storage)高效地将文件批量导入数据库表[reference:14]。然而,COPY INTO的灵活性(支持多种文件格式、压缩算法和错误处理策略)也意味着配置不当或数据质量问题可能导致加载失败或数据错误。
在数据工程实践中,最典型的场景是:数据团队将CSV文件上传到S3存储桶,然后执行COPY INTO将数据加载到Snowflake表中。由于上游数据源的变化、ETL流程的bug或人工操作失误,经常出现列数不匹配、数据类型冲突、CSV格式问题等导致加载失败的情况。更严重的是,如果使用默认的ON_ERROR = 'ABORT_STATEMENT'配置,一个格式有问题的文件就可能导致整个加载任务中止[reference:15]。
二、COPY INTO数据导入的问题分类
2.1 列数不匹配问题
这是最常见的一类错误,当CSV文件中的字段数与目标表的列数不一致时,Snowflake会抛出解析错误。ERROR_ON_COLUMN_COUNT_MISMATCH选项控制此行为,默认值为TRUE[reference:16]。
实际生产中最常出现的情况包括:
- Schema演化未同步更新文件:数据团队在表中添加了新列,但上游生成CSV文件的系统仍按旧结构输出,导致列数不匹配[reference:17]。
- 文件生成逻辑不一致:ETL流程中存在条件逻辑错误,导致不同批次的CSV文件包含不同数量的列[reference:18]。
- 手动编辑文件:人工操作CSV文件时意外增删列。
- 分隔符检测错误:文件使用的分隔符(如|或;)与默认逗号不一致,导致Snowflake错误地解析出错误的列数[reference:19]。
2.2 数据类型不匹配
当CSV字段值无法转换为目标列的数据类型时,加载会失败。常见场景包括:
- 将包含非数字字符的字符串加载到NUMBER类型列
- 日期时间格式与Snowflake预期不符。即使使用ISO标准格式如
2015-10-29 12:44:11.627,也可能触发数据类型不匹配警告[reference:20] - 浮点数格式问题(如使用逗号作为小数点分隔符)
- 特殊字符或转义序列导致解析异常
2.3 CSV格式解析问题
CSV文件本身的格式问题会导致解析错误[reference:21]:
- 字段内包含分隔符:当字段值本身包含逗号时,Snowflake会将逗号误认为字段分隔符
- 引号处理不当 :使用双引号包围字段但未正确设置
FIELD_OPTIONALLY_ENCLOSED_BY参数 - 行内换行符:字段中包含换行符,导致一行数据被错误地拆分为多行
2.4 压缩文件相关问题
Snowflake官方建议在加载大型数据集前压缩数据文件[reference:22],但压缩文件也可能带来额外问题:
- 压缩格式不被自动识别 :虽然Snowflake会自动检测压缩方式,但特定情况下需通过
COMPRESSION选项明确指定 - 双重压缩问题 :如果文件在上传前已压缩,而PUT命令的
AUTO_COMPRESS = TRUE导致再次压缩,可能导致加载失败[reference:23] - 存储类别限制:存档存储类别(如S3 Glacier)的数据需要先恢复才能访问[reference:24]
2.5 错误处理配置
COPY INTO的ON_ERROR参数默认值为ABORT_STATEMENT,遇到第一个错误即中止整个加载[reference:25]。在生产环境中,这可能导致:
- 大量有效数据因个别错误而无法加载
- 难以定位具体的问题数据
- 增加了排查和修复问题的成本
Snowflake提供了多种错误处理选项:CONTINUE(跳过错误行继续加载)、SKIP_FILE(跳过有问题的整个文件)等[reference:26]。
三、自动化验证方案设计与实现
3.1 方案设计原则
针对上述问题,本文提出的自动化验证方案遵循以下设计原则:
- 提前发现问题:在文件上传到S3和执行COPY INTO之前进行本地验证
- 精确定位:明确指出出现问题的行号、列名、问题类型和详细原因
- 可配置:支持通过JSON配置文件灵活调整验证规则和连接参数
- 可审计:输出详细的运行日志和结构化的JSON报告
3.2 验证流程
验证工具的核心流程包括四个步骤:
步骤一:读取CSV文件。使用Pandas读取本地CSV文件,支持gzip压缩文件,自动检测文件编码和分隔符,先以字符串类型读取以避免隐式类型转换带来的信息丢失。
步骤二:获取Snowflake表结构。通过Snowflake的INFORMATION_SCHEMA.COLUMNS视图获取目标表的列信息,包括列名、数据类型、是否允许NULL、字符最大长度等。
步骤三:逐行验证数据。依次检查:
- 列数是否匹配(文件字段数与表列数)
- 列名是否匹配(如果CSV包含表头)
- 非空约束(NOT NULL列不能为空)
- 数据类型兼容性(数值、布尔、日期时间、字符串长度等)
步骤四:生成报告。输出包含问题统计和详情信息的JSON报告,同时记录运行日志。
3.3 关键实现细节
类型验证逻辑:对数值类型进行严格的范围检查(如TINYINT的-128到127范围),对日期时间类型进行常见格式的正则匹配,对字符串类型检查是否超过最大长度限制。
大文件支持 :Pandas的low_memory=False选项优化内存使用,支持处理GB级别的大文件。
编码处理:在UTF-8解码失败时,自动尝试GBK、GB2312等常见中文编码,提高兼容性。
四、最佳实践建议
4.1 数据准备阶段
- 统一数据格式:在生成CSV文件时,尽量使用标准格式(UTF-8编码、统一分隔符、引号包围包含特殊字符的字段)
- 压缩大文件 :对于大型数据集,建议使用gzip压缩,并确保
COMPRESSION选项正确设置 - 验证先行:在正式加载前,使用本文提供的验证工具对小样本数据进行预验证
4.2 COPY INTO配置建议
- 合理设置ON_ERROR :对于生产环境,建议设置
ON_ERROR = 'CONTINUE'或ON_ERROR = 'SKIP_FILE',避免单条错误数据阻塞整个加载[reference:27] - 处理列数差异 :如果列数不匹配是可预期的,设置
ERROR_ON_COLUMN_COUNT_MISMATCH = FALSE,但应仔细评估数据质量影响[reference:28] - 明确文件格式参数 :明确指定
FIELD_DELIMITER、FIELD_OPTIONALLY_ENCLOSED_BY等参数,避免依赖默认值
4.3 监控与调试
- 利用COPY_HISTORY视图:通过SNOWFLAKE.ACCOUNT_USAGE.COPY_HISTORY查询过去365天的加载历史,包括错误信息[reference:29]
- 使用VALIDATE函数 :对于已执行的COPY INTO命令,使用
SELECT * FROM TABLE(VALIDATE(table_name, JOB_ID =>'_last'))获取所有加载错误[reference:30] - 定期审计:建议数据团队定期检查加载日志,识别重复出现的错误模式,从源头改进数据质量
使用COPY INTO从S3导入CSV文件到Snowflake表是一个看似简单但实际充满挑战的任务。从列数不匹配、数据类型冲突到CSV格式问题和压缩文件处理,每一个环节都可能成为数据管道中的瓶颈。本文提出的自动化验证方案,通过在上传前对CSV数据进行全面检查,帮助数据团队提前发现并解决问题,减少了线上故障的发生。
在生产环境中,数据工程师应将此类验证集成到CI/CD流程中,结合合理的COPY INTO参数配置(如ON_ERROR = 'CONTINUE')和定期的加载日志审计,构建健壮、可靠的数据加载管道。只有将主动验证与被动监控相结合,才能确保数据从源头到仓库的完整性和一致性。
五、可能遇到的问题
使用COPY INTO从S3导入CSV文件(包括压缩后的CSV文件)到Snowflake表时,可能遇到以下几类问题:
1. 列数不匹配(Column Count Mismatch)
这是最常见的一类问题。Snowflake默认的ERROR_ON_COLUMN_COUNT_MISMATCH选项为TRUE,当CSV文件中的字段数与目标表的列数不一致时,会抛出解析错误并中止加载[reference:0]。
出现此问题的常见场景包括:
- Schema演化未同步更新文件:目标表新增了列,但上游CSV文件仍按旧结构生成。例如原表有5列,后续通过ALTER TABLE增加了第6列,但CSV文件仍然只包含5列,加载时就会报错[reference:1]。
- 文件生成不一致:ETL流程中存在条件逻辑错误,导致不同批次的CSV文件包含不同数量的列[reference:2]。
- 手动编辑文件:有人在编辑CSV文件时无意中增删了列。
- 分隔符检测错误:CSV文件使用的分隔符与Snowflake默认配置不一致(默认逗号),导致Snowflake错误地解析出错误的列数[reference:3]。
如果CSV文件中的列数与目标表的列数不匹配,可以通过设置ERROR_ON_COLUMN_COUNT_MISMATCH = FALSE,使Snowflake对缺失的列填充NULL,对多余的列忽略[reference:4]。
2. 数据类型不匹配(Data Type Mismatch)
当CSV文件中的字段值无法转换为目标表对应列的数据类型时,加载会失败。例如:
- 将包含文本的字符串加载到NUMBER类型列
- 日期/时间格式与Snowflake预期格式不符。即使使用广泛识别的日期格式如
2015-10-29 12:44:11.627,Snowflake也可能发出数据类型不匹配的警告[reference:5] - 浮点数格式问题(例如使用逗号作为小数点分隔符)
- 特殊字符或转义序列导致解析异常
3. CSV格式解析问题
CSV文件本身的格式问题会导致解析错误:
- 字段内包含分隔符:当字段值中本身包含逗号时,Snowflake会将逗号误认为字段分隔符,导致列错位。解决方案是使用引号包围包含逗号的字段值,或使用其他不常见的分隔符(如竖线|或分号;)[reference:6]。
- 引号处理不当 :字段使用了双引号包围,但未正确设置
FIELD_OPTIONALLY_ENCLOSED_BY参数,导致引号被当作数据的一部分而非格式标识符[reference:7]。 - 行内换行符:CSV字段中包含换行符,会导致一行数据被错误地拆分为多行。
- 转义字符:反斜杠等转义字符未正确处理。
4. 编码问题
CSV文件的字符编码与Snowflake预期不一致可能导致乱码或解析失败。Snowflake默认使用UTF-8编码,如果文件使用其他编码(如GBK、ISO-8859-1等),需要在FILE_FORMAT中通过ENCODING选项明确指定。
5. 压缩文件相关问题
对于压缩后的CSV文件(如CSV.GZ),可能遇到以下问题:
- 压缩格式不被自动识别 :Snowflake虽然会自动检测压缩方式,但某些情况下可能需要通过
COMPRESSION选项明确指定。对于大型数据集,Snowflake官方建议使用压缩[reference:8]。 - 压缩文件上传错误:用户可能会遇到Snowflake提示只能上传特定文件类型的限制(如txt, log, zip, gzip, gz等),需要确认压缩格式是否在支持范围内[reference:9]。
- 压缩与解压缩双重压缩问题 :如果文件在上传前已压缩,而Snowflake在PUT时又自动压缩,可能导致加载失败。Snowflake默认的
AUTO_COMPRESS = TRUE会使文件被再次压缩[reference:10]。
6. 数据质量问题
- NULL值处理不当:当CSV中包含NULL字符串或空值,而目标列不允许NULL时,会引发错误。
- 重复数据:Snowflake会追踪已加载文件的元数据以去重。如果重复加载同一文件,Snowflake会检测到文件已存在并跳过。但如果是文件名不同的重复数据,则需要业务层处理。
- 文件过大或文件数过多:单个文件过大可能导致内存或处理时间问题;一次加载大量小文件可能影响性能。
7. 错误处理配置不当
COPY INTO的ON_ERROR参数控制遇到错误时的行为,默认值为ABORT_STATEMENT,即遇到第一个错误就中止整个加载[reference:11]。在生产和迁移场景中,如果使用默认配置,一个格式有问题的文件就可能导致整个加载失败,造成较大影响[reference:12]。
8. 暂存区和S3相关配置问题
- S3文件路径错误:暂存区路径或文件名不正确
- S3权限问题:Snowflake没有读取S3存储桶的权限(缺少AWS IAM角色或凭证)
- 存储类别问题:存档存储类别(如S3 Glacier)的数据需要先恢复才能访问[reference:13]
六、Python自动化检查程序
以下程序使用Pandas读取本地CSV文件,连接Snowflake获取表结构,逐行检查数据质量,生成详细的验证报告和日志。
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Snowflake COPY INTO CSV 数据验证工具
功能:在将CSV文件上传到S3之前,验证数据与Snowflake表结构的兼容性,
检测列数不匹配、数据类型不兼容、格式错误等问题,并输出详细报告。
"""
import json
import logging
import os
import sys
import gzip
from datetime import datetime
from typing import Dict, List, Optional, Any, Tuple
import pandas as pd
import snowflake.connector
from snowflake.connector import SnowflakeConnection
# ==================== 日志配置 ====================
def setup_logging(log_file: str) ->logging.Logger:
"""配置日志记录器"""
logger = logging.getLogger("SnowflakeDataValidator")
logger.setLevel(logging.DEBUG)
# 清除已有的handlers
if logger.handlers:
logger.handlers.clear()
# 文件handler
file_handler = logging.FileHandler(log_file, encoding='utf-8')
file_handler.setLevel(logging.DEBUG)
file_formatter = logging.Formatter(
'%(asctime)s | %(levelname)s | %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
file_handler.setFormatter(file_formatter)
logger.addHandler(file_handler)
# 控制台handler
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(logging.INFO)
console_formatter = logging.Formatter('%(levelname)s: %(message)s')
console_handler.setFormatter(console_formatter)
logger.addHandler(console_handler)
return logger
# ==================== Snowflake 类型映射 ====================
# Snowflake 数据类型到 Python/Pandas 类型的映射(用于类型检查)
SNOWFLAKE_TYPE_MAP = {
# 数值类型
'NUMBER': 'numeric', 'INT': 'numeric', 'INTEGER': 'numeric',
'BIGINT': 'numeric', 'SMALLINT': 'numeric', 'FLOAT': 'numeric',
'FLOAT8': 'numeric', 'DOUBLE': 'numeric', 'DECIMAL': 'numeric',
'NUMERIC': 'numeric', 'REAL': 'numeric',
# 字符串类型
'VARCHAR': 'string', 'CHAR': 'string', 'CHARACTER': 'string',
'STRING': 'string', 'TEXT': 'string',
# 布尔类型
'BOOLEAN': 'boolean',
# 日期时间类型
'DATE': 'date', 'TIME': 'time', 'DATETIME': 'datetime',
'TIMESTAMP': 'timestamp', 'TIMESTAMP_LTZ': 'timestamp',
'TIMESTAMP_NTZ': 'timestamp', 'TIMESTAMP_TZ': 'timestamp',
# 二进制类型
'BINARY': 'binary', 'VARBINARY': 'binary',
# 半结构化类型
'VARIANT': 'variant', 'OBJECT': 'object', 'ARRAY': 'array',
}
# Snowflake 数值类型的精度和范围限制(部分参考值)
NUMERIC_LIMITS = {
'TINYINT': (-128, 127),
'SMALLINT': (-32768, 32767),
'INT': (-2147483648, 2147483647),
'BIGINT': (-9223372036854775808, 9223372036854775807),
}
# ==================== Snowflake 表结构获取 ====================
def get_snowflake_connection(config: Dict) ->SnowflakeConnection:
"""建立Snowflake数据库连接"""
try:
conn = snowflake.connector.connect(
account=config['account'],
user=config['user'],
password=config['password'],
warehouse=config.get('warehouse'),
database=config['database'],
schema=config.get('schema'),
role=config.get('role')
)
return conn
except Exception as e:
raise Exception(f"连接Snowflake失败: {e}")
def get_table_schema(conn: SnowflakeConnection, database: str, schema: str,
table_name: str) -> List[Dict[str, Any]]:
"""获取目标表的结构信息"""
query = f"""
SELECT
COLUMN_NAME,
DATA_TYPE,
IS_NULLABLE,
CHARACTER_MAXIMUM_LENGTH,
NUMERIC_PRECISION,
NUMERIC_SCALE
FROM {database}.INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = '{schema}'
AND TABLE_NAME = '{table_name}'
ORDER BY ORDINAL_POSITION
"""
try:
cursor = conn.cursor()
cursor.execute(query)
columns = cursor.fetchall()
cursor.close()
schema_info = []
for col in columns:
schema_info.append({
'name': col[0],
'data_type': col[1],
'is_nullable': col[2] == 'YES',
'max_length': col[3],
'precision': col[4],
'scale': col[5]
})
return schema_info
except Exception as e:
raise Exception(f"获取表结构失败: {e}")
# ==================== CSV 数据验证 ====================
def detect_delimiter(file_path: str, sample_size: int = 5) ->str:
"""自动检测CSV分隔符"""
common_delimiters = [',', '|', ';', '\t']
delimiter_counts = {d: 0 for d in common_delimiters}
try:
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
for i, line in enumerate(f):
if i >= sample_size:
break
for delim in common_delimiters:
delimiter_counts[delim] += line.count(delim)
# 返回出现次数最多的分隔符
return max(delimiter_counts, key=delimiter_counts.get)
except:
return ','
def read_csv_with_encoding(file_path: str, delimiter: str = None,
encoding: str = 'utf-8', compression: str = 'infer') -> pd.DataFrame:
"""
读取CSV文件,支持压缩文件
compression参数: 'infer', 'gzip', 'zip', 'bz2', 'xz', 'zstd', None
"""
if delimiter is None:
delimiter = detect_delimiter(file_path)
# 对于压缩文件,pandas可以直接处理
try:
df = pd.read_csv(
file_path,
delimiter=delimiter,
encoding=encoding,
compression=compression,
dtype=str, # 先全部读为字符串,便于验证
low_memory=False
)
return df, delimiter
except UnicodeDecodeError:
# 尝试其他常见编码
for enc in ['gbk', 'gb2312', 'latin-1', 'cp1252']:
try:
df = pd.read_csv(file_path, delimiter=delimiter, encoding=enc,
compression=compression, dtype=str, low_memory=False)
return df, delimiter
except:
continue
raise Exception(f"无法解码文件,尝试了UTF-8、GBK等编码均失败")
def validate_data_type(value: Any, snowflake_type: str, col_info: Dict) ->Tuple[bool, str]:
"""验证单个值是否与Snowflake数据类型兼容"""
if pd.isna(value) or value == '':
return True, "空值(允许NULL)"
value_str = str(value).strip()
# 数值类型验证
if snowflake_type in ['NUMBER', 'INT', 'INTEGER', 'BIGINT', 'SMALLINT',
'FLOAT', 'DOUBLE', 'DECIMAL', 'NUMERIC']:
try:
# 尝试转换为数值
num_val = float(value_str)
# 检查整数类型的范围限制
if snowflake_type in ['TINYINT', 'SMALLINT', 'INT', 'BIGINT']:
if num_val != int(num_val):
return False, f"浮点数不能存入整数类型 {snowflake_type}"
int_val = int(num_val)
if snowflake_type in NUMERIC_LIMITS:
min_val, max_val = NUMERIC_LIMITS[snowflake_type]
if int_val < min_val or int_val > max_val:
return False, f"值 {int_val} 超出 {snowflake_type} 范围 [{min_val}, {max_val}]"
return True, "数值类型匹配"
except ValueError:
return False, f"无法转换为数值类型 {snowflake_type}: '{value_str}'"
# 布尔类型验证
if snowflake_type == 'BOOLEAN':
if value_str.lower() in ['true', 'false', '1', '0', 'yes', 'no']:
return True, "布尔类型匹配"
else:
return False, f"无法转换为布尔类型: '{value_str}'"
# 日期时间类型验证(基本检查)
if snowflake_type in ['DATE', 'TIME', 'DATETIME', 'TIMESTAMP',
'TIMESTAMP_LTZ', 'TIMESTAMP_NTZ', 'TIMESTAMP_TZ']:
# 常见日期格式正则(简化版)
import re
date_patterns = [
r'^\d{4}-\d{1,2}-\d{1,2}$', # YYYY-MM-DD
r'^\d{4}-\d{1,2}-\d{1,2} \d{1,2}:\d{1,2}:\d{1,2}', # YYYY-MM-DD HH:MM:SS
r'^\d{1,2}/\d{1,2}/\d{4}', # MM/DD/YYYY
r'^\d{4}/\d{1,2}/\d{1,2}', # YYYY/MM/DD
]
for pattern in date_patterns:
if re.match(pattern, value_str):
return True, "日期时间格式基本有效"
return False, f"日期时间格式可能无效: '{value_str}'"
# 字符串类型 - 检查长度限制
if snowflake_type in ['VARCHAR', 'CHAR', 'STRING', 'TEXT']:
max_len = col_info.get('max_length')
if max_len and len(value_str) > max_len:
return False, f"字符串长度 {len(value_str)} 超过限制 {max_len}"
return True, "字符串类型匹配"
# 其他类型默认通过
return True, "类型验证通过(非严格检查)"
def validate_csv_data(df: pd.DataFrame, table_schema: List[Dict],
delimiter: str) -> List[Dict]:
"""
逐行验证CSV数据与表结构的兼容性
返回所有问题的列表
"""
issues = []
expected_col_count = len(table_schema)
actual_col_count = len(df.columns)
# 检查列数匹配
if expected_col_count != actual_col_count:
issues.append({
'row_number': 0,
'row_index': None,
'column_name': None,
'column_index': None,
'error_type': 'COLUMN_COUNT_MISMATCH',
'error_detail': f"文件列数 {actual_col_count} 与表列数 {expected_col_count} 不匹配",
'suggestion': '请检查CSV文件是否包含正确的列数。'
'如果列数差异是预期的,可在FILE_FORMAT中设置 '
'ERROR_ON_COLUMN_COUNT_MISMATCH = FALSE'
})
# 如果列数不匹配,后续逐列验证可能不准确,提前返回
return issues
# 验证列名是否匹配(如果CSV有表头)
csv_columns = list(df.columns)
table_columns = [col['name'] for col in table_schema]
if csv_columns != table_columns:
issues.append({
'row_number': 0,
'row_index': None,
'column_name': None,
'column_index': None,
'error_type': 'COLUMN_NAME_MISMATCH',
'error_detail': f"CSV列名 {csv_columns} 与表列名 {table_columns} 不匹配",
'suggestion': '请确保CSV列名与表列名完全一致,或使用 MATCH_BY_COLUMN_NAME 选项'
})
# 逐行验证
for idx, row in df.iterrows():
row_num = idx + 2 # +2: 1-based + 表头行(如果存在)
for col_idx, (col_name, value) in enumerate(row.items()):
# 查找对应的表列信息
target_col = None
for tc in table_schema:
if tc['name'].upper() == col_name.upper():
target_col = tc
break
if target_col is None:
issues.append({
'row_number': row_num,
'row_index': idx,
'column_name': col_name,
'column_index': col_idx,
'error_type': 'COLUMN_NOT_FOUND',
'error_detail': f"列 '{col_name}' 在目标表中不存在",
'suggestion': '请检查列名是否与表结构一致,或使用 MATCH_BY_COLUMN_NAME 选项'
})
continue
# 检查非空约束
if (pd.isna(value) or value == '') and not target_col['is_nullable']:
issues.append({
'row_number': row_num,
'row_index': idx,
'column_name': col_name,
'column_index': col_idx,
'error_type': 'NOT_NULL_VIOLATION',
'error_detail': f"列 '{col_name}' 不允许为NULL,但第{row_num}行为空",
'suggestion': '请为该字段提供有效值,或修改表结构允许NULL'
})
continue
# 数据类型验证
if not (pd.isna(value) or value == ''):
is_valid, message = validate_data_type(
value, target_col['data_type'], target_col
)
if not is_valid:
issues.append({
'row_number': row_num,
'row_index': idx,
'column_name': col_name,
'column_index': col_idx,
'error_type': 'DATA_TYPE_MISMATCH',
'error_detail': f"值 '{value}' {message}",
'suggestion': '请检查数据格式,或在加载前进行数据清洗和转换'
})
return issues
# ==================== 报告生成 ====================
def generate_report(issues: List[Dict], csv_file: str, table_name: str,
total_rows: int, delimiter: str) -> Dict:
"""生成验证报告"""
report = {
'validation_timestamp': datetime.now().isoformat(),
'csv_file': csv_file,
'target_table': table_name,
'delimiter': delimiter,
'total_rows_validated': total_rows,
'total_issues': len(issues),
'issues_by_type': {},
'issues': issues
}
# 按错误类型统计
for issue in issues:
error_type = issue['error_type']
if error_type not in report['issues_by_type']:
report['issues_by_type'][error_type] = 0
report['issues_by_type'][error_type] += 1
return report
def save_report(report: Dict, output_file: str):
"""保存报告到JSON文件"""
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(report, f, ensure_ascii=False, indent=2)
def print_summary(report: Dict):
"""打印验证摘要"""
print("\n" + "=" * 60)
print("数据验证摘要")
print("=" * 60)
print(f"CSV文件: {report['csv_file']}")
print(f"目标表: {report['target_table']}")
print(f"分隔符: {report['delimiter']}")
print(f"验证行数: {report['total_rows_validated']}")
print(f"问题总数: {report['total_issues']}")
print("\n问题类型分布:")
for err_type, count in report['issues_by_type'].items():
print(f" - {err_type}: {count}")
if report['total_issues'] > 0:
print("\n前10个问题详情:")
for issue in report['issues'][:10]:
print(f" 行 {issue['row_number']}, 列 '{issue['column_name']}': "
f"{issue['error_type']} - {issue['error_detail'][:80]}")
# ==================== 主函数 ====================
def load_config(config_file: str) ->Dict:
"""加载配置文件"""
with open(config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
return config
def main():
"""主函数"""
# 配置文件路径(可通过命令行参数指定)
config_file = sys.argv[1] if len(sys.argv) > 1 else 'config.json'
try:
config = load_config(config_file)
except FileNotFoundError:
print(f"错误: 找不到配置文件 '{config_file}'")
print("请创建配置文件,格式示例见下文")
sys.exit(1)
except json.JSONDecodeError as e:
print(f"错误: 配置文件格式无效 - {e}")
sys.exit(1)
# 设置日志
log_file = config.get('log_file', f"validation_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log")
logger = setup_logging(log_file)
logger.info("=" * 60)
logger.info("Snowflake CSV 数据验证工具启动")
logger.info("=" * 60)
try:
# 读取配置参数
csv_file = config['csv_file']
snowflake_config = config['snowflake']
target_table = config['target_table']
# 可选配置
csv_encoding = config.get('csv_encoding', 'utf-8')
compression = config.get('compression', 'infer')
logger.info(f"CSV文件: {csv_file}")
logger.info(f"目标表: {target_table}")
# 步骤1: 读取CSV文件
logger.info("步骤1: 读取CSV文件...")
if compression in ['gzip', 'gz'] or csv_file.endswith('.gz'):
logger.info("检测到gzip压缩文件")
df, delimiter = read_csv_with_encoding(csv_file, encoding=csv_encoding,
compression=compression)
total_rows = len(df)
logger.info(f"读取完成: {total_rows} 行, {len(df.columns)} 列, 分隔符: '{delimiter}'")
# 步骤2: 获取Snowflake表结构
logger.info("步骤2: 获取Snowflake表结构...")
conn = get_snowflake_connection(snowflake_config)
try:
schema_info = get_table_schema(
conn,
snowflake_config['database'],
snowflake_config['schema'],
target_table
)
logger.info(f"表结构获取完成: {len(schema_info)} 列")
finally:
conn.close()
# 步骤3: 验证数据
logger.info("步骤3: 验证CSV数据...")
issues = validate_csv_data(df, schema_info, delimiter)
logger.info(f"验证完成,发现 {len(issues)} 个问题")
# 步骤4: 生成报告
logger.info("步骤4: 生成验证报告...")
report = generate_report(issues, csv_file, target_table, total_rows, delimiter)
report_file = config.get('report_file',
f"validation_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json")
save_report(report, report_file)
logger.info(f"报告已保存到: {report_file}")
# 步骤5: 输出摘要
print_summary(report)
# 记录完成状态
if report['total_issues'] == 0:
logger.info("验证通过,未发现数据问题")
else:
logger.warning(f"验证完成,发现 {report['total_issues']} 个问题,请检查报告")
logger.info("=" * 60)
logger.info("验证完成")
except Exception as e:
logger.error(f"验证过程中发生错误: {e}", exc_info=True)
sys.exit(1)
if __name__ == "__main__":
main()
配置文件示例(config.json)
json
{
"csv_file": "/path/to/your/data.csv",
"csv_encoding": "utf-8",
"compression": "infer",
"log_file": "validation.log",
"report_file": "validation_report.json",
"target_table": "your_table_name",
"snowflake": {
"account": "your_account",
"user": "your_username",
"password": "your_password",
"warehouse": "your_warehouse",
"database": "your_database",
"schema": "your_schema",
"role": "your_role"
}
}
程序使用说明
-
安装依赖:
bashpip install pandas snowflake-connector-python -
创建配置文件 :根据上述示例创建
config.json -
运行程序:
bashpython snowflake_csv_validator.py config.json -
输出说明:
- 控制台:显示验证进度和摘要信息
- 日志文件:记录详细的操作日志
- JSON报告:包含所有问题的详细信息,包括行号、列名、问题类型、详情和解决建议