using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
using Npgsql;
using VolPro.Builder.IServices;
using VolPro.Core.EFDbContext;
namespace VolPro.Builder.Services;
///
/// PostgreSQL 数据库表结构操作实现。
///
public class PgSqlTableProvider : ITableDatabaseProvider
{
private readonly BaseDbContext _context;
private readonly string _schema;
public PgSqlTableProvider(BaseDbContext context)
{
_context = context;
// 从 PG 连接字符串推断 schema:优先 SearchPath / search_path,其次 schema,默认 public
var connStr = _context.SqlSugarClient.Ado.Connection?.ConnectionString;
var schema = "public";
if (!string.IsNullOrWhiteSpace(connStr))
{
try
{
var builder = new NpgsqlConnectionStringBuilder(connStr);
if (!string.IsNullOrWhiteSpace(builder.SearchPath))
{
schema = builder.SearchPath.Split(',')[0].Trim();
}
else if (builder.TryGetValue("schema", out var sVal) && sVal is string s && !string.IsNullOrWhiteSpace(s))
{
schema = s.Trim();
}
}
catch
{
// 解析失败时回退到 public
schema = "public";
}
}
_schema = schema;
}
public async Task TableExistsAsync(string tableName)
{
const string pgSql = "SELECT COUNT(*) as value FROM information_schema.tables WHERE table_schema = @schemaName AND table_name = @tableName;";
var res = await _context.SqlSugarClient.Ado.GetScalarAsync(pgSql, new { schemaName = _schema, tableName });
return Convert.ToInt32(res) > 0;
}
public async Task CreateTableAsync(CreateTableRequest request)
{
await CreateTablePgSqlAsync(request);
}
public async Task> GetAllTablesAsync()
{
const string pgSql = "SELECT table_name as value FROM information_schema.tables WHERE table_schema = @schemaName ORDER BY table_name;";
var rows = await _context.SqlSugarClient.Ado.SqlQueryAsync(pgSql, new { schemaName = _schema });
return rows ?? new List();
}
public async Task GetTableInfoAsync(string tableName)
{
return await GetTableInfoPgSqlAsync(tableName);
}
public async Task UpdateTableAsync(UpdateTableRequest request)
{
await UpdateTablePgSqlAsync(request);
}
public async Task DeleteTableAsync(string tableName)
{
var dropPg = $"DROP TABLE \"{tableName.Replace("\"", "\"\"")}\";";
await _context.SqlSugarClient.Ado.ExecuteCommandAsync(dropPg);
}
private static string NormalizePgSqlDefault(string raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
var s = raw.Trim();
if (s.StartsWith("'") && s.Contains("'::"))
{
var end = s.IndexOf("'::", StringComparison.Ordinal);
if (end > 0)
{
var inner = s[1..end].Replace("''", "'");
return inner;
}
}
if (s.StartsWith("nextval(")) return null;
return s;
}
private static string FormatDefaultForPgSql(string value)
{
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
var v = value.Trim();
var vLower = v.ToLower();
if (vLower is "current_timestamp" or "now()" or "localtimestamp")
return $" DEFAULT {v}";
if (long.TryParse(v, out _) || decimal.TryParse(v, out _))
return $" DEFAULT {v}";
if (vLower == "true" || vLower == "1") return " DEFAULT true";
if (vLower == "false" || vLower == "0") return " DEFAULT false";
return $" DEFAULT '{v.Replace("'", "''")}'";
}
// PGSql 类型映射
private static string BuildPgSqlColumnType(TableColumnDto column)
{
var dt = (column.DataType ?? string.Empty).Trim();
var dtLower = dt.ToLower();
return dtLower switch
{
"nvarchar" or "varchar" or "character varying" => $"VARCHAR({(column.Length is > 0 ? column.Length : 255)})",
"char" or "nchar" => $"CHAR({(column.Length is > 0 ? column.Length : 1)})",
"nvarchar(max)" or "varchar(max)" or "text" or "ntext" => "TEXT",
"int" or "integer" => "INTEGER",
"bigint" => "BIGINT",
"tinyint" or "bit" => "SMALLINT",
"decimal" or "numeric" =>
$"NUMERIC({(column.Length ?? 18)},{(column.Scale ?? 0)})",
"float" or "double" => "DOUBLE PRECISION",
"datetime" => "TIMESTAMP",
"date" => "DATE",
"uniqueidentifier" or "uuid" => "UUID",
_ => dt
};
}
// PGSql 专用:建表
private async Task CreateTablePgSqlAsync(CreateTableRequest request)
{
var columnDefs = new List();
foreach (var column in request.Columns.OrderBy(c => c.Order))
{
var typeSql = BuildPgSqlColumnType(column);
var isIntLike = typeSql.StartsWith("INTEGER", StringComparison.OrdinalIgnoreCase) ||
typeSql.StartsWith("INT4", StringComparison.OrdinalIgnoreCase) ||
typeSql.StartsWith("INT8", StringComparison.OrdinalIgnoreCase) ||
typeSql.StartsWith("INT4", StringComparison.OrdinalIgnoreCase) ||
typeSql.StartsWith("INT2", StringComparison.OrdinalIgnoreCase) ||
typeSql.StartsWith("BIGINT", StringComparison.OrdinalIgnoreCase);
var wantIdentity = column.IsIdentity && column.IsPrimaryKey && isIntLike;
// PGSql:自增主键使用 GENERATED BY DEFAULT AS IDENTITY
var identitySql = wantIdentity ? " GENERATED ALWAYS AS IDENTITY " : string.Empty;
var nullSql = column.IsNullable && !column.IsPrimaryKey
? "NULL"
: "NOT NULL";
var defaultSql = FormatDefaultForPgSql(column.DefaultValue);
columnDefs.Add($"\"{column.ColumnName}\" {typeSql}{identitySql} {nullSql}{defaultSql}");
}
var pkCols = request.Columns.Where(c => c.IsPrimaryKey)
.OrderBy(c => c.Order)
.Select(c => $"\"{c.ColumnName}\"");
var pkSql = pkCols.Any() ? $", CONSTRAINT \"PK_{request.TableName}\" PRIMARY KEY ({string.Join(", ", pkCols)})" : string.Empty;
var createSql = $"CREATE TABLE \"{request.TableName}\" (\n {string.Join(",\n ", columnDefs)}{pkSql}\n);";
await _context.SqlSugarClient.Ado.ExecuteCommandAsync(createSql);
foreach (var column in request.Columns.Where(c => !string.IsNullOrWhiteSpace(c.Comment)))
{
var commentSql = $"COMMENT ON COLUMN \"{request.TableName}\".\"{column.ColumnName}\" IS '{column.Comment!.Replace("'", "''")}';";
await _context.SqlSugarClient.Ado.ExecuteCommandAsync(commentSql);
}
}
// PGSql 专用:获取表结构
private async Task GetTableInfoPgSqlAsync(string tableName)
{
const string sql = @"
SELECT c.column_name, c.data_type, c.character_maximum_length, c.is_nullable, c.ordinal_position,
c.numeric_scale, c.numeric_precision,
CASE WHEN pk.column_name IS NOT NULL THEN 1 ELSE 0 END AS is_primary_key,
CASE WHEN c.column_default LIKE 'nextval(%' THEN 1 ELSE 0 END AS is_identity,
col_description(format('%I.%I', c.table_schema, c.table_name)::regclass::oid, a.attnum) AS comment,
c.column_default
FROM information_schema.columns c
LEFT JOIN (SELECT kcu.table_schema, kcu.table_name, kcu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
WHERE tc.constraint_type = 'PRIMARY KEY') pk
ON pk.table_schema = c.table_schema AND pk.table_name = c.table_name AND pk.column_name = c.column_name
JOIN pg_attribute a ON a.attname = c.column_name AND a.attrelid = format('%I.%I', c.table_schema, c.table_name)::regclass
WHERE c.table_schema = @schemaName AND c.table_name = @tableName ORDER BY c.ordinal_position;";
var dt = await _context.SqlSugarClient.Ado.GetDataTableAsync(sql, new { schemaName = _schema, tableName });
var columns = new List();
foreach (DataRow row in dt.Rows)
{
var dataType = row[1]?.ToString() ?? "";
var isDecimal = dataType.Equals("numeric", StringComparison.OrdinalIgnoreCase) || dataType.Equals("decimal", StringComparison.OrdinalIgnoreCase);
int? charLen = null;
if (row[2] != DBNull.Value && row[2] != null)
{
var raw = Convert.ToInt64(row[2]);
if (raw > 0 && raw <= int.MaxValue) charLen = (int)raw;
}
var numericScale = row[5] == DBNull.Value || row[5] == null ? (int?)null : Convert.ToInt32(row[5]);
var numericPrecision = row[6] == DBNull.Value || row[6] == null ? (int?)null : Convert.ToInt32(row[6]);
var colDefault = row[10] == DBNull.Value || row[10] == null ? null : NormalizePgSqlDefault(row[10]?.ToString());
columns.Add(new TableColumnDto
{
ColumnName = row[0]?.ToString() ?? "",
DataType = dataType,
Length = isDecimal ? numericPrecision : charLen,
Scale = isDecimal ? numericScale : null,
IsNullable = string.Equals(row[3]?.ToString(), "YES", StringComparison.OrdinalIgnoreCase),
Order = Convert.ToInt32(row[4]),
IsPrimaryKey = row[7] != DBNull.Value && row[7] != null && Convert.ToInt32(row[7]) == 1,
IsIdentity = row[8] != DBNull.Value && row[8] != null && Convert.ToInt32(row[8]) == 1,
Comment = row[9]?.ToString(),
DefaultValue = colDefault ?? string.Empty
});
}
return new TableInfoDto { TableName = tableName, Columns = columns };
}
// PGSql 专用:更新表(新增/删除/修改列 + 主键 & 注释)
private async Task UpdateTablePgSqlAsync(UpdateTableRequest request)
{
var existing = await GetTableInfoPgSqlAsync(request.TableName) ?? throw new InvalidOperationException("Table not found.");
var existingByName = existing.Columns.ToDictionary(c => c.ColumnName, c => c, StringComparer.Ordinal);
var newNames = request.Columns.Select(c => c.ColumnName).ToHashSet(StringComparer.Ordinal);
// 1) 识别并执行重命名字段(区分大小写)
var renames = request.Columns
.Where(c => c.IsDbField && !string.IsNullOrWhiteSpace(c.OriginalColumnName) &&
c.OriginalColumnName != c.ColumnName &&
existingByName.Keys.Any(k => string.Equals(k, c.OriginalColumnName, StringComparison.OrdinalIgnoreCase)))
.ToList();
foreach (var r in renames)
{
var actualKey = existingByName.Keys.FirstOrDefault(k => string.Equals(k, r.OriginalColumnName, StringComparison.OrdinalIgnoreCase));
if (actualKey == null) continue;
var renameSql = $"ALTER TABLE \"{request.TableName}\" RENAME COLUMN \"{actualKey.Replace("\"", "\"\"")}\" TO \"{r.ColumnName.Replace("\"", "\"\"")}\";";
await _context.SqlSugarClient.Ado.ExecuteCommandAsync(renameSql);
var oldCol = existingByName[actualKey];
existingByName.Remove(actualKey);
existingByName[r.ColumnName] = new TableColumnDto { ColumnName = r.ColumnName, DataType = oldCol.DataType, Length = oldCol.Length, Scale = oldCol.Scale, IsNullable = oldCol.IsNullable, IsPrimaryKey = oldCol.IsPrimaryKey, IsIdentity = oldCol.IsIdentity, Comment = oldCol.Comment, DefaultValue = oldCol.DefaultValue ?? "", Order = oldCol.Order };
}
var existingNames = existingByName.Keys.ToHashSet(StringComparer.Ordinal);
var columnsToDrop = existingNames.Except(newNames, StringComparer.Ordinal).ToList();
// 2) 新增列
foreach (var column in request.Columns.Where(c => !existingByName.ContainsKey(c.ColumnName)).OrderBy(c => c.Order))
{
var typeSql = BuildPgSqlColumnType(column);
var isIdentity = column.IsIdentity &&
(typeSql.StartsWith("INTEGER", StringComparison.OrdinalIgnoreCase) ||
typeSql.StartsWith("BIGINT", StringComparison.OrdinalIgnoreCase));
var identitySql = isIdentity ? " GENERATED ALWAYS AS IDENTITY" : string.Empty;
var nullSql = column.IsNullable && !column.IsPrimaryKey
? "NULL"
: "NOT NULL";
var defaultSql = FormatDefaultForPgSql(column.DefaultValue);
var addColSql = $"ALTER TABLE \"{request.TableName}\" ADD COLUMN \"{column.ColumnName}\" {typeSql}{identitySql} {nullSql}{defaultSql};";
await _context.SqlSugarClient.Ado.ExecuteCommandAsync(addColSql);
if (!string.IsNullOrWhiteSpace(column.Comment))
{
var commentSql = $"COMMENT ON COLUMN \"{request.TableName}\".\"{column.ColumnName}\" IS '{column.Comment!.Replace("'", "''")}';";
await _context.SqlSugarClient.Ado.ExecuteCommandAsync(commentSql);
}
}
// 3) 修改已有列:仅当有变更时才执行 ALTER
foreach (var column in request.Columns.Where(c => existingByName.ContainsKey(c.ColumnName)))
{
var typeSql = BuildPgSqlColumnType(column);
var old = existingByName[column.ColumnName];
var isIntegerLike = typeSql.StartsWith("INTEGER", StringComparison.OrdinalIgnoreCase) ||
typeSql.StartsWith("BIGINT", StringComparison.OrdinalIgnoreCase);
var wantIdentity = column.IsIdentity && column.IsPrimaryKey && isIntegerLike;
var oldTypeSql = BuildPgSqlColumnType(old);
var typeChanged = !string.Equals(oldTypeSql, typeSql, StringComparison.OrdinalIgnoreCase);
var nullableChanged = old.IsNullable != column.IsNullable;
var defaultChanged = (old.DefaultValue ?? "").Trim() != (column.DefaultValue ?? "").Trim();
var identityChanged = isIntegerLike && old.IsIdentity != wantIdentity;
var commentChanged = (old.Comment ?? "").Trim() != (column.Comment ?? "").Trim();
if (typeChanged)
{
var alterTypeSql = $"ALTER TABLE \"{request.TableName}\" ALTER COLUMN \"{column.ColumnName}\" TYPE {typeSql};";
await _context.SqlSugarClient.Ado.ExecuteCommandAsync(alterTypeSql);
}
if (nullableChanged)
{
var nullSql = column.IsNullable && !column.IsPrimaryKey ? "DROP NOT NULL" : "SET NOT NULL";
await _context.SqlSugarClient.Ado.ExecuteCommandAsync($"ALTER TABLE \"{request.TableName}\" ALTER COLUMN \"{column.ColumnName}\" {nullSql};");
}
if (defaultChanged)
{
var defaultSql = FormatDefaultForPgSql(column.DefaultValue);
if (!string.IsNullOrWhiteSpace(defaultSql))
await _context.SqlSugarClient.Ado.ExecuteCommandAsync($"ALTER TABLE \"{request.TableName}\" ALTER COLUMN \"{column.ColumnName}\" SET {defaultSql.Trim()};");
else
try { await _context.SqlSugarClient.Ado.ExecuteCommandAsync($"ALTER TABLE \"{request.TableName}\" ALTER COLUMN \"{column.ColumnName}\" DROP DEFAULT;"); } catch { }
}
if (identityChanged)
{
if (wantIdentity && !old.IsIdentity)
await _context.SqlSugarClient.Ado.ExecuteCommandAsync($"ALTER TABLE \"{request.TableName}\" ALTER COLUMN \"{column.ColumnName}\" ADD GENERATED BY DEFAULT AS IDENTITY;");
else if (!wantIdentity && old.IsIdentity)
await _context.SqlSugarClient.Ado.ExecuteCommandAsync($"ALTER TABLE \"{request.TableName}\" ALTER COLUMN \"{column.ColumnName}\" DROP IDENTITY IF EXISTS;");
}
if (commentChanged)
{
var commentText = string.IsNullOrWhiteSpace(column.Comment) ? "NULL" : $"'{column.Comment!.Replace("'", "''")}'";
await _context.SqlSugarClient.Ado.ExecuteCommandAsync($"COMMENT ON COLUMN \"{request.TableName}\".\"{column.ColumnName}\" IS {commentText};");
}
}
foreach (var colName in columnsToDrop)
{
await _context.SqlSugarClient.Ado.ExecuteCommandAsync($"ALTER TABLE \"{request.TableName}\" DROP COLUMN \"{colName}\";");
}
var newPkCols = request.Columns.Where(c => c.IsPrimaryKey).OrderBy(c => c.Order).Select(c => c.ColumnName).ToList();
var existingPkCols = existingByName.Values.Where(c => c.IsPrimaryKey).OrderBy(c => c.Order).Select(c => c.ColumnName).ToList();
var pkChanged = !newPkCols.SequenceEqual(existingPkCols);
if (pkChanged)
{
const string findPkSql = "SELECT c.conname FROM pg_constraint c JOIN pg_class t ON c.conrelid = t.oid JOIN pg_namespace n ON n.oid = t.relnamespace WHERE c.contype = 'p' AND n.nspname = @schemaName AND t.relname = @tableName;";
var pkDt = await _context.SqlSugarClient.Ado.GetDataTableAsync(findPkSql, new { schemaName = _schema, tableName = request.TableName });
foreach (DataRow r in pkDt.Rows)
{
var pkName = r[0]?.ToString();
if (!string.IsNullOrEmpty(pkName))
await _context.SqlSugarClient.Ado.ExecuteCommandAsync($"ALTER TABLE \"{_schema}\".\"{request.TableName}\" DROP CONSTRAINT \"{pkName}\";");
}
if (newPkCols.Any())
{
var pkColsSql = string.Join(", ", newPkCols.Select(c => $"\"{c}\""));
await _context.SqlSugarClient.Ado.ExecuteCommandAsync($"ALTER TABLE \"{_schema}\".\"{request.TableName}\" ADD CONSTRAINT \"PK_{request.TableName}\" PRIMARY KEY ({pkColsSql});");
}
}
}
}