1385 lines
58 KiB
C#
1385 lines
58 KiB
C#
using ExperionCrawler.Core.Application.Interfaces;
|
||
using ExperionCrawler.Core.Domain.Entities;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.Extensions.Logging;
|
||
using Npgsql;
|
||
using System.Text.Json;
|
||
using System.Globalization;
|
||
|
||
namespace ExperionCrawler.Infrastructure.Database;
|
||
|
||
// ── DbContext ────────────────────────────────────────────────────────────────
|
||
|
||
public class ExperionDbContext : DbContext
|
||
{
|
||
public ExperionDbContext(DbContextOptions<ExperionDbContext> options) : base(options) { }
|
||
|
||
public DbSet<ExperionRecord> ExperionRecords => Set<ExperionRecord>();
|
||
public DbSet<RawNodeMap> RawNodeMaps => Set<RawNodeMap>();
|
||
public DbSet<NodeMapMaster> NodeMapMasters => Set<NodeMapMaster>();
|
||
public DbSet<RealtimePoint> RealtimePoints => Set<RealtimePoint>();
|
||
public DbSet<HistoryRecord> HistoryRecords => Set<HistoryRecord>();
|
||
public DbSet<FastSession> FastSessions => Set<FastSession>();
|
||
public DbSet<FastRecord> FastRecords => Set<FastRecord>();
|
||
public DbSet<TagMetadata> TagMetadata => Set<TagMetadata>();
|
||
|
||
// P&ID 데이터베이스용 DbSet
|
||
public DbSet<PidEquipment> PidEquipment => Set<PidEquipment>();
|
||
public DbSet<PidAuditLog> PidAuditLog => Set<PidAuditLog>();
|
||
public DbSet<PidGraphStatus> PidGraphStatuses => Set<PidGraphStatus>();
|
||
|
||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||
{
|
||
modelBuilder.Entity<ExperionRecord>(e =>
|
||
{
|
||
e.HasKey(x => x.Id);
|
||
e.HasIndex(x => x.CollectedAt);
|
||
e.HasIndex(x => x.NodeId);
|
||
e.HasIndex(x => x.SessionId);
|
||
});
|
||
|
||
modelBuilder.Entity<RawNodeMap>(e =>
|
||
{
|
||
e.HasKey(x => x.Id);
|
||
e.HasIndex(x => x.NodeId);
|
||
});
|
||
|
||
modelBuilder.Entity<NodeMapMaster>(e =>
|
||
{
|
||
e.HasKey(x => x.Id);
|
||
e.HasIndex(x => x.NodeId);
|
||
e.HasIndex(x => x.Level);
|
||
});
|
||
|
||
modelBuilder.Entity<RealtimePoint>(e =>
|
||
{
|
||
e.HasKey(x => x.Id);
|
||
e.HasIndex(x => x.NodeId).IsUnique();
|
||
e.HasIndex(x => x.TagName);
|
||
});
|
||
|
||
modelBuilder.Entity<HistoryRecord>(e =>
|
||
{
|
||
e.HasKey(x => x.Id);
|
||
e.HasIndex(x => x.TagName);
|
||
e.HasIndex(x => x.RecordedAt);
|
||
});
|
||
|
||
modelBuilder.Entity<FastSession>(e =>
|
||
{
|
||
e.HasKey(x => x.Id);
|
||
e.HasIndex(x => x.Status);
|
||
e.HasIndex(x => x.StartedAt);
|
||
e.Property(x => x.TagList).HasColumnType("jsonb");
|
||
});
|
||
|
||
modelBuilder.Entity<FastRecord>(e =>
|
||
{
|
||
e.HasKey(x => new { x.SessionId, x.RecordedAt, x.TagName });
|
||
e.HasIndex(x => x.SessionId);
|
||
});
|
||
|
||
modelBuilder.Entity<TagMetadata>(e =>
|
||
{
|
||
e.HasKey(x => x.Id);
|
||
e.HasIndex(x => new { x.BaseTag, x.Attribute }).IsUnique();
|
||
e.HasIndex(x => x.BaseTag);
|
||
});
|
||
|
||
// P&ID 엔티티 설정
|
||
modelBuilder.Entity<PidEquipment>(entity =>
|
||
{
|
||
entity.ToTable("pid_equipment");
|
||
entity.HasKey(e => e.Id);
|
||
|
||
entity.Property(e => e.TagNo)
|
||
.IsRequired()
|
||
.HasMaxLength(50);
|
||
|
||
entity.Property(e => e.EquipmentName)
|
||
.HasMaxLength(200);
|
||
|
||
entity.Property(e => e.InstrumentType)
|
||
.HasMaxLength(10);
|
||
|
||
entity.Property(e => e.LineNumber)
|
||
.HasMaxLength(100);
|
||
|
||
entity.Property(e => e.PidDrawingNo)
|
||
.HasMaxLength(50);
|
||
|
||
entity.Property(e => e.Confidence)
|
||
.HasPrecision(4, 3);
|
||
|
||
entity.Property(e => e.IsActive)
|
||
.HasDefaultValue(true);
|
||
|
||
entity.Property(e => e.ExtractedAt)
|
||
.HasDefaultValueSql("NOW()");
|
||
|
||
entity.Property(e => e.UpdatedAt)
|
||
.ValueGeneratedOnAddOrUpdate()
|
||
.HasDefaultValueSql("NOW()");
|
||
|
||
entity.HasIndex(e => e.TagNo);
|
||
entity.HasIndex(e => e.InstrumentType);
|
||
entity.HasIndex(e => e.ExtractedAt);
|
||
|
||
entity.HasOne(e => e.ExperionTag)
|
||
.WithMany()
|
||
.HasForeignKey(e => e.ExperionTagId)
|
||
.OnDelete(DeleteBehavior.SetNull);
|
||
});
|
||
|
||
modelBuilder.Entity<PidAuditLog>(entity =>
|
||
{
|
||
entity.ToTable("pid_audit_log");
|
||
entity.HasKey(e => e.Id);
|
||
|
||
entity.Property(e => e.Source)
|
||
.HasMaxLength(50)
|
||
.HasDefaultValue("WebUI");
|
||
|
||
entity.Property(e => e.Action)
|
||
.HasMaxLength(50);
|
||
|
||
entity.Property(e => e.TargetTagNo)
|
||
.HasMaxLength(50);
|
||
|
||
entity.Property(e => e.LoggedAt)
|
||
.HasDefaultValueSql("NOW()");
|
||
|
||
entity.HasIndex(e => e.LoggedAt);
|
||
});
|
||
|
||
modelBuilder.Entity<PidGraphStatus>(entity =>
|
||
{
|
||
entity.ToTable("pid_graph_status");
|
||
entity.HasKey(e => e.TaskId);
|
||
|
||
entity.Property(e => e.Status)
|
||
.HasMaxLength(20);
|
||
|
||
entity.Property(e => e.Message)
|
||
.HasMaxLength(500);
|
||
|
||
entity.HasIndex(e => e.UpdatedAt);
|
||
});
|
||
}
|
||
}
|
||
|
||
// ── Service ──────────────────────────────────────────────────────────────────
|
||
|
||
public class ExperionDbService : IExperionDbService
|
||
{
|
||
private readonly ExperionDbContext _ctx;
|
||
private readonly ILogger<ExperionDbService> _logger;
|
||
|
||
public ExperionDbService(ExperionDbContext ctx, ILogger<ExperionDbService> logger)
|
||
{
|
||
_ctx = ctx;
|
||
_logger = logger;
|
||
}
|
||
|
||
public async Task<bool> InitializeAsync()
|
||
{
|
||
try
|
||
{
|
||
await _ctx.Database.EnsureCreatedAsync();
|
||
|
||
// ── fast_session / fast_record 테이블 생성 ────────────────────────────────
|
||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||
CREATE TABLE IF NOT EXISTS fast_session (
|
||
id SERIAL PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
started_at TIMESTAMPTZ NOT NULL,
|
||
ended_at TIMESTAMPTZ,
|
||
status TEXT NOT NULL DEFAULT 'Pending',
|
||
sampling_ms INTEGER NOT NULL,
|
||
duration_sec INTEGER NOT NULL,
|
||
tag_list JSONB NOT NULL DEFAULT '[]',
|
||
row_count INTEGER NOT NULL DEFAULT 0,
|
||
retention_days INTEGER,
|
||
pinned BOOLEAN NOT NULL DEFAULT FALSE
|
||
)
|
||
""");
|
||
|
||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||
CREATE TABLE IF NOT EXISTS fast_record (
|
||
session_id INTEGER NOT NULL REFERENCES fast_session(id) ON DELETE CASCADE,
|
||
recorded_at TIMESTAMPTZ NOT NULL,
|
||
tagname TEXT NOT NULL,
|
||
value TEXT,
|
||
PRIMARY KEY (session_id, recorded_at, tagname)
|
||
)
|
||
""");
|
||
|
||
// PK 마이그레이션: 기존 테이블 PK에 recorded_at 없으면 수정 (TimescaleDB hypertable 요건)
|
||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||
DO $$
|
||
BEGIN
|
||
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'fast_record' AND schemaname = 'public')
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM pg_constraint c
|
||
JOIN pg_class t ON t.oid = c.conrelid
|
||
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(c.conkey)
|
||
WHERE t.relname = 'fast_record' AND c.contype = 'p' AND a.attname = 'recorded_at'
|
||
)
|
||
THEN
|
||
ALTER TABLE fast_record DROP CONSTRAINT IF EXISTS fast_record_pkey;
|
||
ALTER TABLE fast_record ADD PRIMARY KEY (session_id, recorded_at, tagname);
|
||
END IF;
|
||
END $$;
|
||
""");
|
||
|
||
// TimescaleDB hypertable 생성 (recorded_at 기준, chunk_interval = 1 day)
|
||
// create_default_indexes => FALSE: PK 인덱스를 TimescaleDB가 중복 생성하지 않도록 함
|
||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||
SELECT create_hypertable('fast_record', 'recorded_at', if_not_exists => TRUE, migrate_data => TRUE, create_default_indexes => FALSE)
|
||
""");
|
||
|
||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||
SELECT set_chunk_time_interval('fast_record', INTERVAL '1 day')
|
||
""");
|
||
|
||
// TimeScaleDB 확장 활성화
|
||
await _ctx.Database.ExecuteSqlRawAsync("CREATE EXTENSION IF NOT EXISTS timescaledb");
|
||
|
||
// EnsureCreatedAsync는 기존 DB에 새 테이블을 추가하지 않으므로
|
||
// raw_node_map / node_map_master 는 DDL로 직접 보장
|
||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||
CREATE TABLE IF NOT EXISTS raw_node_map (
|
||
id SERIAL PRIMARY KEY,
|
||
level INTEGER NOT NULL,
|
||
class TEXT NOT NULL,
|
||
name TEXT NOT NULL,
|
||
node_id TEXT NOT NULL,
|
||
data_type TEXT NOT NULL
|
||
)
|
||
""");
|
||
|
||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||
CREATE TABLE IF NOT EXISTS node_map_master (
|
||
id SERIAL PRIMARY KEY,
|
||
level INTEGER NOT NULL,
|
||
class TEXT NOT NULL,
|
||
name TEXT NOT NULL,
|
||
node_id TEXT NOT NULL,
|
||
data_type TEXT NOT NULL
|
||
)
|
||
""");
|
||
|
||
// realtime_table 생성 (실시간 모니터링 포인트)
|
||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||
CREATE TABLE IF NOT EXISTS realtime_table (
|
||
id SERIAL PRIMARY KEY,
|
||
tagname TEXT NOT NULL,
|
||
node_id TEXT NOT NULL,
|
||
livevalue TEXT,
|
||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
)
|
||
""");
|
||
|
||
// realtime_table은 실시간 값 저장용이므로 하이퍼테이블로 변환하지 않음
|
||
|
||
// tag_metadata 테이블 생성 (메타데이터 - 변경 드묾)
|
||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||
CREATE TABLE IF NOT EXISTS tag_metadata (
|
||
id SERIAL PRIMARY KEY,
|
||
base_tag TEXT NOT NULL,
|
||
attribute TEXT NOT NULL,
|
||
value TEXT,
|
||
node_id TEXT,
|
||
loaded_at TIMESTAMPTZ DEFAULT NOW(),
|
||
UNIQUE(base_tag, attribute)
|
||
)
|
||
""");
|
||
|
||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||
CREATE INDEX IF NOT EXISTS idx_tag_metadata_base ON tag_metadata(base_tag)
|
||
""");
|
||
|
||
// v_tag_summary 뷰 생성 (실시간 + 메타데이터 통합)
|
||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||
CREATE OR REPLACE VIEW v_tag_summary AS
|
||
SELECT
|
||
rt_base.base_tag,
|
||
pv_rt.livevalue AS pv,
|
||
sp_rt.livevalue AS sp,
|
||
op_rt.livevalue AS op,
|
||
instate0_rt.livevalue AS instate0,
|
||
instate1_rt.livevalue AS instate1,
|
||
instate2_rt.livevalue AS instate2,
|
||
desc_md.value AS description,
|
||
area_md.value AS area,
|
||
s0d_md.value AS state0_descriptor,
|
||
s1d_md.value AS state1_descriptor,
|
||
s2d_md.value AS state2_descriptor
|
||
FROM (SELECT DISTINCT split_part(tagname, '.', 1) AS base_tag FROM realtime_table) rt_base
|
||
LEFT JOIN realtime_table pv_rt ON pv_rt.tagname = rt_base.base_tag || '.pv'
|
||
LEFT JOIN realtime_table sp_rt ON sp_rt.tagname = rt_base.base_tag || '.sp'
|
||
LEFT JOIN realtime_table op_rt ON op_rt.tagname = rt_base.base_tag || '.op'
|
||
LEFT JOIN realtime_table instate0_rt ON instate0_rt.tagname = rt_base.base_tag || '.instate0'
|
||
LEFT JOIN realtime_table instate1_rt ON instate1_rt.tagname = rt_base.base_tag || '.instate1'
|
||
LEFT JOIN realtime_table instate2_rt ON instate2_rt.tagname = rt_base.base_tag || '.instate2'
|
||
LEFT JOIN tag_metadata desc_md ON desc_md.base_tag = rt_base.base_tag AND desc_md.attribute = 'desc'
|
||
LEFT JOIN tag_metadata area_md ON area_md.base_tag = rt_base.base_tag AND area_md.attribute = 'area'
|
||
LEFT JOIN tag_metadata s0d_md ON s0d_md.base_tag = rt_base.base_tag AND s0d_md.attribute = 'state0descriptor'
|
||
LEFT JOIN tag_metadata s1d_md ON s1d_md.base_tag = rt_base.base_tag AND s1d_md.attribute = 'state1descriptor'
|
||
LEFT JOIN tag_metadata s2d_md ON s2d_md.base_tag = rt_base.base_tag AND s2d_md.attribute = 'state2descriptor'
|
||
""");
|
||
|
||
// history 테이블은 수동으로 하이퍼테이블 생성 필요
|
||
// CreateHypertableAsync() 메서드를 사용하여 수동 생성 가능
|
||
// 참고: 하이퍼테이블 생성 후 보존 정책, 압축 정책, 연속 집계 설정은
|
||
// CreateHypertableAsync() 메서드에서 선택적으로 설정 가능
|
||
|
||
_logger.LogInformation("[ExperionDb] 데이터베이스 초기화 완료 (TimeScaleDB 활성화)");
|
||
return true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "[ExperionDb] 초기화 실패");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// history 테이블을 TimeScaleDB 하이퍼테이블로 생성/마이그레이션
|
||
/// </summary>
|
||
/// <param name="hypertableName">생성할 하이퍼테이블 이름 (예: history_hypertable)</param>
|
||
/// <param name="tableName">기존 테이블 이름 (예: history_table, null이면 새 테이블 생성)</param>
|
||
/// <param name="timeColumn">시간 컬럼 이름</param>
|
||
/// <param name="interval">청크 간격 (예: '1 day')</param>
|
||
/// <returns>하이퍼테이블 생성 여부 (true: 생성됨/기존 있음, false: 실패/스킵)</returns>
|
||
private async Task<bool> CreateHistoryHypertableIfNotExistsAsync(
|
||
string hypertableName, string? tableName, string timeColumn, string interval)
|
||
{
|
||
try
|
||
{
|
||
// SQL injection 방지를 위해 식별자 검증
|
||
if (!IsValidSqlIdentifier(hypertableName))
|
||
{
|
||
_logger.LogWarning("[ExperionDb] 하이퍼테이블 이름 '{HypertableName}'이(가) 유효하지 않음", hypertableName);
|
||
return false;
|
||
}
|
||
|
||
// 1️⃣ 하이퍼테이블이 이미 존재하면 스킵
|
||
await using var conn = new NpgsqlConnection(_ctx.Database.GetConnectionString());
|
||
await conn.OpenAsync();
|
||
|
||
await using var cmd1 = new NpgsqlCommand(
|
||
$"SELECT 1 FROM pg_catalog.pg_tables WHERE tablename = '{hypertableName.Replace("'", "''")}' LIMIT 1", conn);
|
||
var result = await cmd1.ExecuteScalarAsync();
|
||
if (result != null)
|
||
{
|
||
_logger.LogInformation("[ExperionDb] 하이퍼테이블 '{HypertableName}' 이미 존재함", hypertableName);
|
||
return true;
|
||
}
|
||
|
||
// 2️⃣ 기존 테이블 이름이 제공되면 검증
|
||
if (tableName != null && !IsValidSqlIdentifier(tableName))
|
||
{
|
||
_logger.LogWarning("[ExperionDb] 테이블 이름 '{TableName}'이(가) 유효하지 않음", tableName);
|
||
return false;
|
||
}
|
||
|
||
// 3️⃣ 기존 테이블이 존재하면 → 하이퍼테이블로 마이그레이션
|
||
if (tableName != null)
|
||
{
|
||
await using var cmd2 = new NpgsqlCommand(
|
||
"SELECT 1 FROM pg_catalog.pg_tables WHERE tablename = @tableName LIMIT 1", conn);
|
||
cmd2.Parameters.AddWithValue("@tableName", tableName.Replace("'", "''"));
|
||
result = await cmd2.ExecuteScalarAsync();
|
||
if (result != null)
|
||
{
|
||
// 데이터가 있는 경우 migrate_data => true 옵션 필요
|
||
await using var cmd3 = new NpgsqlCommand(
|
||
"SELECT create_hypertable(@tableName, @timeColumn, chunk_time_interval => INTERVAL @interval, create_default_indexes => true, migrate_data => true)", conn);
|
||
cmd3.Parameters.AddWithValue("@tableName", tableName);
|
||
cmd3.Parameters.AddWithValue("@timeColumn", timeColumn);
|
||
cmd3.Parameters.AddWithValue("@interval", interval);
|
||
await cmd3.ExecuteNonQueryAsync();
|
||
_logger.LogInformation("[ExperionDb] 테이블 '{TableName}'을(를) 하이퍼테이블로 변환 완료", tableName);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// 4️⃣ 기존 테이블이 없으면 → 새 하이퍼테이블 테이블 생성
|
||
// TimeScaleDB 요구사항: 고유 인덱스를 위해서는 partitioning 컬럼이 primary key에 포함되어야 함
|
||
await using var cmd4 = new NpgsqlCommand(
|
||
@"
|
||
CREATE TABLE IF NOT EXISTS @hypertableName (
|
||
id SERIAL,
|
||
tagname TEXT NOT NULL,
|
||
node_id TEXT,
|
||
value TEXT,
|
||
livevalue TEXT,
|
||
@timeColumn TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
PRIMARY KEY (id, @timeColumn)
|
||
)", conn);
|
||
cmd4.Parameters.AddWithValue("@hypertableName", hypertableName);
|
||
cmd4.Parameters.AddWithValue("@timeColumn", timeColumn);
|
||
await cmd4.ExecuteNonQueryAsync();
|
||
|
||
await using var cmd5 = new NpgsqlCommand(
|
||
"SELECT create_hypertable(@hypertableName, @timeColumn, chunk_time_interval => INTERVAL @interval, create_default_indexes => true)", conn);
|
||
cmd5.Parameters.AddWithValue("@hypertableName", hypertableName);
|
||
cmd5.Parameters.AddWithValue("@timeColumn", timeColumn);
|
||
cmd5.Parameters.AddWithValue("@interval", interval);
|
||
await cmd5.ExecuteNonQueryAsync();
|
||
_logger.LogInformation("[ExperionDb] 새 하이퍼테이블 '{HypertableName}' 생성 완료", hypertableName);
|
||
return true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "[ExperionDb] 하이퍼테이블 '{HypertableName}' 생성 실패 (기본 테이블 사용 계속)", hypertableName);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
public async Task<int> SaveRecordsAsync(IEnumerable<ExperionRecord> records)
|
||
{
|
||
var list = records.ToList();
|
||
await _ctx.ExperionRecords.AddRangeAsync(list);
|
||
var saved = await _ctx.SaveChangesAsync();
|
||
_logger.LogInformation("[ExperionDb] {Count}건 저장", saved);
|
||
return saved;
|
||
}
|
||
|
||
public async Task<int> ClearRecordsAsync()
|
||
{
|
||
var deleted = await _ctx.ExperionRecords.ExecuteDeleteAsync();
|
||
_logger.LogInformation("[ExperionDb] {Count}건 삭제 (초기화)", deleted);
|
||
return deleted;
|
||
}
|
||
|
||
public async Task<int> BuildMasterFromRawAsync(bool truncate = false)
|
||
{
|
||
if (truncate)
|
||
{
|
||
await _ctx.Database.ExecuteSqlRawAsync(
|
||
"TRUNCATE TABLE node_map_master RESTART IDENTITY");
|
||
_logger.LogInformation("[ExperionDb] node_map_master 초기화 완료");
|
||
}
|
||
|
||
var inserted = await _ctx.Database.ExecuteSqlRawAsync(
|
||
"INSERT INTO node_map_master (level, class, name, node_id, data_type) " +
|
||
"SELECT level, class, name, node_id, data_type FROM raw_node_map");
|
||
|
||
_logger.LogInformation("[ExperionDb] node_map_master 빌드 완료: {Count}건", inserted);
|
||
return inserted;
|
||
}
|
||
|
||
public async Task<IEnumerable<ExperionRecord>> GetRecordsAsync(
|
||
DateTime? from = null, DateTime? to = null, int limit = 1000)
|
||
{
|
||
var q = _ctx.ExperionRecords.AsQueryable();
|
||
if (from.HasValue) q = q.Where(r => r.CollectedAt >= from.Value);
|
||
if (to.HasValue) q = q.Where(r => r.CollectedAt <= to.Value);
|
||
return await q.OrderByDescending(r => r.CollectedAt).Take(limit).ToListAsync();
|
||
}
|
||
|
||
public async Task<int> GetTotalCountAsync()
|
||
=> await _ctx.ExperionRecords.CountAsync();
|
||
|
||
public async Task<IEnumerable<string>> GetNameListAsync()
|
||
{
|
||
return await _ctx.NodeMapMasters
|
||
.Select(x => x.Name).Distinct()
|
||
.OrderBy(x => x).ToListAsync();
|
||
}
|
||
|
||
public async Task<NodeMapStats> GetMasterStatsAsync()
|
||
{
|
||
if (!await _ctx.NodeMapMasters.AnyAsync())
|
||
return new NodeMapStats(0, 0, 0, 0, Enumerable.Empty<string>());
|
||
|
||
var total = await _ctx.NodeMapMasters.CountAsync();
|
||
var objectCount = await _ctx.NodeMapMasters.CountAsync(x => x.Class == "Object");
|
||
var variableCount = await _ctx.NodeMapMasters.CountAsync(x => x.Class == "Variable");
|
||
var maxLevel = await _ctx.NodeMapMasters.MaxAsync(x => (int?)x.Level) ?? 0;
|
||
var dataTypes = await _ctx.NodeMapMasters
|
||
.Select(x => x.DataType).Distinct()
|
||
.OrderBy(x => x).ToListAsync();
|
||
|
||
_logger.LogInformation("[ExperionDb] 노드맵 통계: total={Total}", total);
|
||
return new NodeMapStats(total, objectCount, variableCount, maxLevel, dataTypes);
|
||
}
|
||
|
||
// ── RealtimeTable ─────────────────────────────────────────────────────────
|
||
|
||
private static string ExtractTagName(string nodeId)
|
||
{
|
||
var idx = nodeId.LastIndexOf(':');
|
||
return idx >= 0 ? nodeId[(idx + 1)..] : nodeId;
|
||
}
|
||
|
||
public async Task<int> BuildRealtimeTableAsync(
|
||
IEnumerable<string> names, IEnumerable<string> dataTypes)
|
||
{
|
||
var nameList = names.Where(n => !string.IsNullOrEmpty(n)).ToList();
|
||
var dtList = dataTypes.Where(d => !string.IsNullOrEmpty(d)).ToList();
|
||
|
||
var q = _ctx.NodeMapMasters.AsQueryable();
|
||
if (nameList.Count > 0) q = q.Where(x => nameList.Contains(x.Name));
|
||
if (dtList.Count > 0) q = q.Where(x => dtList.Contains(x.DataType));
|
||
|
||
var sources = await q.ToListAsync();
|
||
|
||
await _ctx.Database.ExecuteSqlRawAsync(
|
||
"TRUNCATE TABLE realtime_table RESTART IDENTITY");
|
||
|
||
var points = sources.Select(s => new RealtimePoint
|
||
{
|
||
TagName = ExtractTagName(s.NodeId),
|
||
NodeId = s.NodeId,
|
||
LiveValue = null,
|
||
Timestamp = DateTime.UtcNow
|
||
}).ToList();
|
||
|
||
await _ctx.RealtimePoints.AddRangeAsync(points);
|
||
var saved = await _ctx.SaveChangesAsync();
|
||
_logger.LogInformation("[ExperionDb] realtime_table 빌드: {Count}건", saved);
|
||
return saved;
|
||
}
|
||
|
||
public async Task<IEnumerable<RealtimePoint>> GetRealtimePointsAsync()
|
||
{
|
||
try
|
||
{
|
||
var points = await _ctx.RealtimePoints
|
||
.OrderBy(x => x.TagName)
|
||
.ToListAsync();
|
||
|
||
_logger.LogInformation("[Realtime] 포인트 조회 완료: {Count}건", points.Count);
|
||
return points;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "[Realtime] 포인트 조회 실패");
|
||
return Enumerable.Empty<RealtimePoint>();
|
||
}
|
||
}
|
||
|
||
public async Task<RealtimePoint> AddRealtimePointAsync(string nodeId)
|
||
{
|
||
var existing = await _ctx.RealtimePoints.FirstOrDefaultAsync(x => x.NodeId == nodeId);
|
||
if (existing != null) return existing;
|
||
|
||
var point = new RealtimePoint
|
||
{
|
||
TagName = ExtractTagName(nodeId),
|
||
NodeId = nodeId,
|
||
LiveValue = null,
|
||
Timestamp = DateTime.UtcNow
|
||
};
|
||
_ctx.RealtimePoints.Add(point);
|
||
await _ctx.SaveChangesAsync();
|
||
_logger.LogInformation("[ExperionDb] 수동 추가: {NodeId}", nodeId);
|
||
return point;
|
||
}
|
||
|
||
public async Task<bool> DeleteRealtimePointAsync(int id)
|
||
{
|
||
var point = await _ctx.RealtimePoints.FindAsync(id);
|
||
if (point == null) return false;
|
||
_ctx.RealtimePoints.Remove(point);
|
||
await _ctx.SaveChangesAsync();
|
||
return true;
|
||
}
|
||
|
||
public async Task<int> UpdateLiveValueAsync(string nodeId, string? value, DateTime timestamp)
|
||
{
|
||
return await _ctx.RealtimePoints
|
||
.Where(x => x.NodeId == nodeId)
|
||
.ExecuteUpdateAsync(s => s
|
||
.SetProperty(x => x.LiveValue, value)
|
||
.SetProperty(x => x.Timestamp, timestamp));
|
||
}
|
||
|
||
public async Task<int> BatchUpdateLiveValuesAsync(IEnumerable<LiveValueUpdate> updates)
|
||
{
|
||
var list = updates.ToList();
|
||
if (list.Count == 0) return 0;
|
||
|
||
// 단일 DbContext(단일 DB 커넥션)에서 순차 업데이트 — 커넥션 폭발 방지
|
||
int total = 0;
|
||
foreach (var u in list)
|
||
{
|
||
total += await _ctx.RealtimePoints
|
||
.Where(x => x.NodeId == u.NodeId)
|
||
.ExecuteUpdateAsync(s => s
|
||
.SetProperty(x => x.LiveValue, u.Value)
|
||
.SetProperty(x => x.Timestamp, u.Timestamp));
|
||
}
|
||
return total;
|
||
}
|
||
|
||
// ── HistoryTable ──────────────────────────────────────────────────────────
|
||
|
||
public async Task<int> SnapshotToHistoryAsync()
|
||
{
|
||
var now = DateTime.UtcNow;
|
||
var points = await _ctx.RealtimePoints.ToListAsync();
|
||
if (points.Count == 0) return 0;
|
||
|
||
var rows = points.Select(p => new HistoryRecord
|
||
{
|
||
TagName = p.TagName,
|
||
NodeId = p.NodeId,
|
||
Value = p.LiveValue,
|
||
RecordedAt = now
|
||
}).ToList();
|
||
|
||
await _ctx.HistoryRecords.AddRangeAsync(rows);
|
||
var saved = await _ctx.SaveChangesAsync();
|
||
_logger.LogDebug("[ExperionDb] history 스냅샷: {Count}건 @ {Time:HH:mm:ss}", saved, now);
|
||
return saved;
|
||
}
|
||
|
||
public async Task<IEnumerable<string>> GetTagNamesAsync()
|
||
=> await _ctx.RealtimePoints.Select(x => x.TagName).OrderBy(x => x).ToListAsync();
|
||
|
||
public async Task<HistoryQueryResult> QueryHistoryAsync(
|
||
IEnumerable<string> tagNames, DateTime? from, DateTime? to, int limit)
|
||
{
|
||
var tags = tagNames.Where(t => !string.IsNullOrEmpty(t)).ToList();
|
||
|
||
var q = _ctx.HistoryRecords.AsQueryable();
|
||
if (tags.Count > 0) q = q.Where(x => tags.Contains(x.TagName));
|
||
if (from.HasValue) q = q.Where(x => x.RecordedAt >= from.Value);
|
||
if (to.HasValue) q = q.Where(x => x.RecordedAt <= to.Value);
|
||
|
||
var rows = await q.OrderBy(x => x.RecordedAt)
|
||
.Take(Math.Min(limit, 5000))
|
||
.ToListAsync();
|
||
|
||
// recorded_at 기준으로 행을 묶어서 pivot 구성
|
||
var grouped = rows
|
||
.GroupBy(x => x.RecordedAt)
|
||
.Select(g => new HistoryRow(
|
||
g.Key,
|
||
g.GroupBy(r => r.TagName)
|
||
.ToDictionary(tg => tg.Key, tg => tg.Last().Value)
|
||
as IReadOnlyDictionary<string, string?>))
|
||
.ToList();
|
||
|
||
var usedTags = tags.Count > 0
|
||
? tags
|
||
: rows.Select(x => x.TagName).Distinct().OrderBy(x => x).ToList();
|
||
|
||
return new HistoryQueryResult(usedTags, grouped);
|
||
}
|
||
|
||
// ── History Interval Query ──────────────────────────────────────────────────
|
||
|
||
public async Task<HistoryIntervalQueryResult> QueryHistoryWithIntervalAsync(
|
||
HistoryIntervalQueryRequest request)
|
||
{
|
||
const int BaseIntervalSeconds = 60; // history_table 기본 저장 간격 (60초)
|
||
|
||
var tags = request.TagNames.Where(t => !string.IsNullOrEmpty(t)).ToList();
|
||
var limit = Math.Min(request.Limit, 5000);
|
||
|
||
// SQL 인젝션 방지를 위해 식별자 검증
|
||
if (!IsValidSqlIdentifier("history_table"))
|
||
{
|
||
throw new ArgumentException("Invalid table name");
|
||
}
|
||
|
||
// 간격 파싱 (예: "1 minute", "5 minutes", "1 hour", "10 seconds")
|
||
var intervalStr = ParseIntervalToPostgresInterval(request.Interval);
|
||
|
||
await _ctx.Database.GetDbConnection().OpenAsync();
|
||
|
||
try
|
||
{
|
||
// TimeScaleDB time_bucket 함수를 사용한 간격별 집계 쿼리
|
||
var sql = BuildHistoryIntervalQuerySql(tags, request.From, request.To, intervalStr, limit);
|
||
|
||
using var cmd = new NpgsqlCommand(sql, _ctx.Database.GetDbConnection() as NpgsqlConnection);
|
||
|
||
// 파라미터 바인딩 (Npgsql는 @paramName 형식 지원)
|
||
if (tags.Count > 0)
|
||
{
|
||
var tagParam = cmd.CreateParameter();
|
||
tagParam.ParameterName = "tagNames";
|
||
tagParam.Value = tags.ToArray();
|
||
cmd.Parameters.Add(tagParam);
|
||
}
|
||
if (request.From.HasValue)
|
||
{
|
||
var fromParam = cmd.CreateParameter();
|
||
fromParam.ParameterName = "fromTime";
|
||
fromParam.Value = request.From.Value;
|
||
cmd.Parameters.Add(fromParam);
|
||
}
|
||
if (request.To.HasValue)
|
||
{
|
||
var toParam = cmd.CreateParameter();
|
||
toParam.ParameterName = "toTime";
|
||
toParam.Value = request.To.Value;
|
||
cmd.Parameters.Add(toParam);
|
||
}
|
||
|
||
using var reader = await cmd.ExecuteReaderAsync();
|
||
var rows = new List<HistoryIntervalRow>();
|
||
var allTagNames = new HashSet<string>();
|
||
|
||
while (await reader.ReadAsync())
|
||
{
|
||
var timeBucket = reader.GetDateTime(0);
|
||
var values = new Dictionary<string, string?>();
|
||
|
||
for (int i = 1; i < reader.FieldCount; i++)
|
||
{
|
||
var tagName = reader.GetName(i);
|
||
allTagNames.Add(tagName);
|
||
values[tagName] = reader.IsDBNull(i) ? null : reader.GetString(i);
|
||
}
|
||
|
||
rows.Add(new HistoryIntervalRow(timeBucket, values));
|
||
}
|
||
|
||
var usedTags = tags.Count > 0
|
||
? tags
|
||
: allTagNames.OrderBy(x => x).ToList();
|
||
|
||
return new HistoryIntervalQueryResult(
|
||
usedTags,
|
||
rows,
|
||
BaseIntervalSeconds,
|
||
request.Interval);
|
||
}
|
||
finally
|
||
{
|
||
await _ctx.Database.GetDbConnection().CloseAsync();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 사용자 지정 간격으로 history 이력 조회용 SQL 생성
|
||
/// TimeScaleDB time_bucket 함수를 사용하여 간격별 집계 수행
|
||
/// </summary>
|
||
private string BuildHistoryIntervalQuerySql(
|
||
List<string> tags, DateTime? from, DateTime? to, string intervalStr, int limit)
|
||
{
|
||
var selectParts = new List<string>();
|
||
selectParts.Add($"time_bucket('{intervalStr}', recorded_at) AS time_bucket");
|
||
|
||
// 태그명별로 동적으로 컬럼 생성 (PIVOT)
|
||
// TimeScaleDB에서는 crosstab 함수를 사용하거나, 동적 SQL로 처리
|
||
// 여기서는 간단하게 tagname GROUP BY로 조회 후 앱에서 PIVOT
|
||
selectParts.Add("tagname");
|
||
selectParts.Add("last(value, recorded_at) AS value");
|
||
|
||
var sql = $"SELECT {string.Join(", ", selectParts)} FROM history_table WHERE 1=1";
|
||
|
||
if (tags.Count > 0)
|
||
{
|
||
sql += $" AND tagname = ANY(ARRAY[{string.Join(", ", tags.Select(t => $"'{t.Replace("'", "''")}'"))}])";
|
||
}
|
||
|
||
if (from.HasValue)
|
||
{
|
||
sql += $" AND recorded_at >= @fromTime";
|
||
}
|
||
|
||
if (to.HasValue)
|
||
{
|
||
sql += $" AND recorded_at <= @toTime";
|
||
}
|
||
|
||
sql += $" GROUP BY time_bucket, tagname ORDER BY time_bucket, tagname LIMIT {limit}";
|
||
|
||
return sql;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 사용자 입력 간격을 PostgreSQL INTERVAL 형식으로 변환
|
||
/// 예: "1 minute" → "1 minute", "5 minutes" → "5 minutes", "1 hour" → "1 hour"
|
||
/// </summary>
|
||
private string ParseIntervalToPostgresInterval(string interval)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(interval))
|
||
return "1 minute";
|
||
|
||
var lower = interval.ToLower().Trim();
|
||
|
||
// 이미 PostgreSQL 형식인 경우 (예: "1 minute", "5 minutes", "1 hour")
|
||
if (System.Text.RegularExpressions.Regex.IsMatch(lower, @"^\d+\s+(second|minute|hour|day|week)s?$"))
|
||
{
|
||
return lower;
|
||
}
|
||
|
||
// 숫자만 있는 경우 (초 단위)
|
||
if (int.TryParse(lower, out var seconds))
|
||
{
|
||
if (seconds >= 3600 && seconds % 3600 == 0)
|
||
return $"{seconds / 3600} hour{(seconds / 3600 > 1 ? "s" : "")}";
|
||
if (seconds >= 60 && seconds % 60 == 0)
|
||
return $"{seconds / 60} minute{(seconds / 60 > 1 ? "s" : "")}";
|
||
return $"{seconds} second{(seconds > 1 ? "s" : "")}";
|
||
}
|
||
|
||
// 기본값
|
||
return "1 minute";
|
||
}
|
||
|
||
public async Task<NodeMapQueryResult> QueryMasterAsync(
|
||
int? minLevel, int? maxLevel, string? nodeClass,
|
||
IEnumerable<string>? names, string? nodeId, string? dataType,
|
||
int limit, int offset)
|
||
{
|
||
var q = _ctx.NodeMapMasters.AsQueryable();
|
||
|
||
if (minLevel.HasValue) q = q.Where(x => x.Level >= minLevel.Value);
|
||
if (maxLevel.HasValue) q = q.Where(x => x.Level <= maxLevel.Value);
|
||
if (!string.IsNullOrEmpty(nodeClass)) q = q.Where(x => x.Class == nodeClass);
|
||
var nameList = names?.Where(n => !string.IsNullOrEmpty(n)).ToList();
|
||
if (nameList?.Count > 0) q = q.Where(x => nameList.Contains(x.Name));
|
||
if (!string.IsNullOrEmpty(nodeId)) q = q.Where(x => x.NodeId.Contains(nodeId));
|
||
if (!string.IsNullOrEmpty(dataType)) q = q.Where(x => x.DataType == dataType);
|
||
|
||
var total = await q.CountAsync();
|
||
var items = await q.OrderBy(x => x.Level).ThenBy(x => x.Name)
|
||
.Skip(offset).Take(Math.Min(limit, 500))
|
||
.ToListAsync();
|
||
|
||
return new NodeMapQueryResult(total, items);
|
||
}
|
||
|
||
/// <summary>realtime_table × node_map_master 조인 → nodeId → dataType 사전 반환</summary>
|
||
public async Task<IReadOnlyDictionary<string, string>> GetRealtimeNodeDataTypesAsync()
|
||
{
|
||
var result = await (
|
||
from rt in _ctx.RealtimePoints
|
||
join nm in _ctx.NodeMapMasters on rt.NodeId equals nm.NodeId into joined
|
||
from nm in joined.DefaultIfEmpty()
|
||
select new { rt.NodeId, DataType = nm == null ? "String" : nm.DataType }
|
||
).ToListAsync();
|
||
|
||
return result
|
||
.GroupBy(x => x.NodeId)
|
||
.ToDictionary(g => g.Key, g => g.First().DataType);
|
||
}
|
||
|
||
public async Task<IEnumerable<RealtimePoint>> GetRealtimeRecordsByTagNamesAsync(IEnumerable<string> tagNames)
|
||
{
|
||
try
|
||
{
|
||
var tags = tagNames.ToList();
|
||
if (tags.Count == 0) return Enumerable.Empty<RealtimePoint>();
|
||
|
||
var records = await _ctx.RealtimePoints
|
||
.Where(x => tags.Contains(x.TagName))
|
||
.ToListAsync();
|
||
|
||
_logger.LogInformation("[Realtime] 태그 {Count}개의 라이브 데이터 조회 완료", tags.Count);
|
||
return records;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "[Realtime] 태그 라이브 데이터 조회 실패");
|
||
return Enumerable.Empty<RealtimePoint>();
|
||
}
|
||
}
|
||
|
||
// ── FastSession / FastRecord ─────────────────────────────────────────────────
|
||
|
||
public async Task<FastSession> CreateFastSessionAsync(FastSessionCreateRequest request)
|
||
{
|
||
var session = new FastSession
|
||
{
|
||
Name = request.Name,
|
||
SamplingMs = request.SamplingMs,
|
||
DurationSec = request.DurationSec,
|
||
TagList = JsonSerializer.Serialize(request.TagList), // string[] → JSONB
|
||
StartedAt = DateTime.UtcNow,
|
||
Status = "Running",
|
||
RowCount = 0,
|
||
RetentionDays = request.RetentionDays,
|
||
Pinned = false
|
||
};
|
||
_ctx.FastSessions.Add(session);
|
||
await _ctx.SaveChangesAsync();
|
||
return session;
|
||
}
|
||
|
||
public async Task UpdateFastSessionStatusAsync(int sessionId, string status)
|
||
{
|
||
var session = await _ctx.FastSessions.FindAsync(sessionId);
|
||
if (session == null) return;
|
||
session.Status = status;
|
||
if (status is "Completed" or "Cancelled" or "Failed" or "RowLimitReached")
|
||
session.EndedAt = DateTime.UtcNow;
|
||
await _ctx.SaveChangesAsync();
|
||
}
|
||
|
||
public async Task UpdateFastSessionRowCountAsync(int sessionId, int rowCount)
|
||
{
|
||
var session = await _ctx.FastSessions.FindAsync(sessionId);
|
||
if (session == null) return;
|
||
session.RowCount = rowCount;
|
||
await _ctx.SaveChangesAsync();
|
||
}
|
||
|
||
public async Task UpdateFastSessionPinnedAsync(int sessionId, bool pinned)
|
||
{
|
||
var session = await _ctx.FastSessions.FindAsync(sessionId);
|
||
if (session == null) return;
|
||
session.Pinned = pinned;
|
||
await _ctx.SaveChangesAsync();
|
||
}
|
||
|
||
public async Task<FastSession?> GetFastSessionAsync(int sessionId)
|
||
=> await _ctx.FastSessions.FindAsync(sessionId);
|
||
|
||
public async Task<IEnumerable<FastSession>> GetFastSessionsAsync()
|
||
=> await _ctx.FastSessions.OrderByDescending(x => x.StartedAt).ToListAsync();
|
||
|
||
public async Task DeleteFastSessionAsync(int sessionId)
|
||
{
|
||
var session = await _ctx.FastSessions.FindAsync(sessionId);
|
||
if (session == null) return;
|
||
_ctx.FastSessions.Remove(session);
|
||
await _ctx.SaveChangesAsync();
|
||
}
|
||
|
||
public async Task<IEnumerable<FastSession>> GetExpiredFastSessionsAsync()
|
||
{
|
||
var now = DateTime.UtcNow;
|
||
return await _ctx.FastSessions
|
||
.Where(x => x.EndedAt != null
|
||
&& !x.Pinned
|
||
&& x.RetentionDays.HasValue
|
||
&& x.EndedAt.Value.AddDays(x.RetentionDays.Value) < now)
|
||
.OrderBy(x => x.EndedAt)
|
||
.ToListAsync();
|
||
}
|
||
|
||
public async Task<FastQueryResult> GetFastRecordsAsync(int sessionId, DateTime? from, DateTime? to)
|
||
{
|
||
var query = _ctx.FastRecords.Where(x => x.SessionId == sessionId);
|
||
if (from.HasValue) query = query.Where(x => x.RecordedAt >= from.Value);
|
||
if (to.HasValue) query = query.Where(x => x.RecordedAt <= to.Value);
|
||
|
||
var records = await query.OrderBy(x => x.RecordedAt).ToListAsync();
|
||
var tagNames = records.Select(x => x.TagName).Distinct().ToArray();
|
||
var items = records.Select(r => new FastRecord
|
||
{
|
||
Id = r.Id,
|
||
SessionId = r.SessionId,
|
||
RecordedAt = r.RecordedAt,
|
||
TagName = r.TagName,
|
||
Value = r.Value
|
||
});
|
||
|
||
return new FastQueryResult(
|
||
SessionId: sessionId,
|
||
From: from ?? records.MinBy(x => x.RecordedAt)?.RecordedAt ?? DateTime.UtcNow,
|
||
To: to ?? records.MaxBy(x => x.RecordedAt)?.RecordedAt ?? DateTime.UtcNow,
|
||
TagNames: tagNames,
|
||
Items: items,
|
||
TotalCount: records.Count
|
||
);
|
||
}
|
||
|
||
public async Task BatchInsertFastRecordsAsync(IEnumerable<FastRecord> records)
|
||
{
|
||
await _ctx.FastRecords.AddRangeAsync(records);
|
||
await _ctx.SaveChangesAsync();
|
||
}
|
||
|
||
public async Task ExportFastRecordsToCsvAsync(int sessionId, Stream stream, DateTime? from, DateTime? to)
|
||
{
|
||
var query = _ctx.FastRecords.Where(x => x.SessionId == sessionId);
|
||
if (from.HasValue) query = query.Where(x => x.RecordedAt >= from.Value);
|
||
if (to.HasValue) query = query.Where(x => x.RecordedAt <= to.Value);
|
||
|
||
var records = await query.OrderBy(x => x.RecordedAt).ThenBy(x => x.TagName).ToListAsync();
|
||
var tagNames = records.Select(x => x.TagName).Distinct().OrderBy(x => x).ToArray();
|
||
|
||
using var writer = new StreamWriter(stream, leaveOpen: true);
|
||
await writer.WriteLineAsync("recorded_at," + string.Join(",", tagNames));
|
||
|
||
foreach (var g in records.GroupBy(x => x.RecordedAt).OrderBy(g => g.Key))
|
||
{
|
||
var values = g.ToDictionary(r => r.TagName, r => r.Value);
|
||
var row = g.Key.ToString("o") + "," +
|
||
string.Join(",", tagNames.Select(t => values.TryGetValue(t, out var v) ? $"\"{v}\"" : ""));
|
||
await writer.WriteLineAsync(row);
|
||
}
|
||
await writer.FlushAsync();
|
||
}
|
||
|
||
public async Task<string?> GetNodeIdByTagNameAsync(string tagName)
|
||
{
|
||
return await _ctx.RealtimePoints
|
||
.Where(x => x.TagName == tagName)
|
||
.Select(x => x.NodeId)
|
||
.FirstOrDefaultAsync();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 하이퍼테이블 상태 조회합니다.
|
||
/// 하이퍼테이블인지 여부, 레코드 수, 보존 정책, 압축, 연속 집계 설정 등을 확인합니다.
|
||
/// </summary>
|
||
public async Task<HypertableStatusInfo> GetHypertableStatusAsync()
|
||
{
|
||
try
|
||
{
|
||
await _ctx.Database.GetDbConnection().OpenAsync();
|
||
|
||
// 하이퍼테이블 존재 여부 확인
|
||
await using var hypertableCheckCmd = _ctx.Database.GetDbConnection().CreateCommand();
|
||
hypertableCheckCmd.CommandText = @"
|
||
SELECT EXISTS (
|
||
SELECT 1 FROM timescaledb_information.hypertables
|
||
WHERE hypertable_name = 'history_table'
|
||
)";
|
||
|
||
var isHypertableResult = Convert.ToBoolean(await hypertableCheckCmd.ExecuteScalarAsync());
|
||
|
||
// 레코드 수 조회
|
||
var recordCount = 0;
|
||
if (isHypertableResult)
|
||
{
|
||
await using var countCmd = _ctx.Database.GetDbConnection().CreateCommand();
|
||
countCmd.CommandText = "SELECT COUNT(*) FROM history_table";
|
||
recordCount = Convert.ToInt32(await countCmd.ExecuteScalarAsync());
|
||
}
|
||
|
||
// 보존 정책 확인 (pg_extension으로 TimeScaleDB 활성화 여부만 확인)
|
||
var hasRetentionPolicy = false;
|
||
try
|
||
{
|
||
await using var retentionCmd = _ctx.Database.GetDbConnection().CreateCommand();
|
||
retentionCmd.CommandText = @"
|
||
SELECT EXISTS (
|
||
SELECT 1 FROM timescaledb_information.policies
|
||
WHERE policy_type = 'data_retention'
|
||
AND hypertable_name = 'history_table'
|
||
)";
|
||
hasRetentionPolicy = Convert.ToBoolean(await retentionCmd.ExecuteScalarAsync());
|
||
}
|
||
catch
|
||
{
|
||
// policies 뷰가 없는 경우 false 유지
|
||
hasRetentionPolicy = false;
|
||
}
|
||
|
||
// 압축 확인
|
||
var hasCompression = false;
|
||
try
|
||
{
|
||
await using var compressionCmd = _ctx.Database.GetDbConnection().CreateCommand();
|
||
compressionCmd.CommandText = @"
|
||
SELECT is_compressed = 't'
|
||
FROM timescaledb_information.hypertables
|
||
WHERE hypertable_name = 'history_table'";
|
||
var compressionResult = await compressionCmd.ExecuteScalarAsync();
|
||
if (compressionResult != null)
|
||
{
|
||
hasCompression = compressionResult.ToString() == "t";
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// 압축 정보 조회 실패 시 false 유지
|
||
hasCompression = false;
|
||
}
|
||
|
||
// 연속 집계 확인
|
||
var hasContinuousAggregate = false;
|
||
try
|
||
{
|
||
await using var aggregateCmd = _ctx.Database.GetDbConnection().CreateCommand();
|
||
aggregateCmd.CommandText = @"
|
||
SELECT EXISTS (
|
||
SELECT 1 FROM timescaledb_information.continuous_aggregates
|
||
WHERE hypertable_name = 'history_table'
|
||
)";
|
||
hasContinuousAggregate = Convert.ToBoolean(await aggregateCmd.ExecuteScalarAsync());
|
||
}
|
||
catch
|
||
{
|
||
// continuous_aggregates 뷰가 없는 경우 false 유지
|
||
hasContinuousAggregate = false;
|
||
}
|
||
|
||
return new HypertableStatusInfo
|
||
{
|
||
IsHypertable = isHypertableResult,
|
||
TableName = "history_table",
|
||
StatusMessage = isHypertableResult
|
||
? "하이퍼테이블이 활성화되어 있습니다."
|
||
: "일반 테이블입니다. CreateHypertableAsync()를 사용하여 하이퍼테이블로 변환할 수 있습니다.",
|
||
RecordCount = recordCount,
|
||
HasRetentionPolicy = hasRetentionPolicy,
|
||
HasCompression = hasCompression,
|
||
HasContinuousAggregate = hasContinuousAggregate
|
||
};
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return new HypertableStatusInfo
|
||
{
|
||
IsHypertable = false,
|
||
TableName = "history_table",
|
||
StatusMessage = $"상태 확인 중 오류 발생: {ex.Message}",
|
||
RecordCount = 0,
|
||
HasRetentionPolicy = false,
|
||
HasCompression = false,
|
||
HasContinuousAggregate = false
|
||
};
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 수동으로 하이퍼테이블을 생성합니다.
|
||
/// 테이블이 이미 존재하거나 하이퍼테이블로 변환된 경우 예외를 throw합니다.
|
||
/// </summary>
|
||
public async Task<HypertableCreateResult> CreateHypertableAsync(HypertableCreateRequest request)
|
||
{
|
||
// 식별자 검증 - SQL injection 방지
|
||
if (!IsValidSqlIdentifier(request.TableName))
|
||
{
|
||
return HypertableCreateResult.Failed($"테이블 이름 '{request.TableName}'은 유효하지 않습니다. 영문, 숫자, 언더스코어, 하이픈, 마침표만 사용 가능합니다.");
|
||
}
|
||
|
||
if (!IsValidSqlIdentifier(request.TimeColumn))
|
||
{
|
||
return HypertableCreateResult.Failed($"시간 컬럼 이름 '{request.TimeColumn}'은 유효하지 않습니다. 영문, 숫자, 언더스코어, 하이픈, 마침표만 사용 가능합니다.");
|
||
}
|
||
|
||
var timeInterval = request.TimeInterval ?? "";
|
||
if (!IsValidSqlIdentifier(timeInterval.Replace(" ", "")))
|
||
{
|
||
return HypertableCreateResult.Failed($"시간 간격 '{request.TimeInterval ?? "(null)"}'은 유효하지 않습니다.");
|
||
}
|
||
|
||
try
|
||
{
|
||
_ctx.Database.GetDbConnection().Open();
|
||
|
||
// 1. 테이블 존재 여부 확인
|
||
await using var tableCheckCmd = _ctx.Database.GetDbConnection().CreateCommand();
|
||
tableCheckCmd.CommandText = @"
|
||
SELECT EXISTS (
|
||
SELECT 1 FROM information_schema.tables
|
||
WHERE table_schema = 'public'
|
||
AND table_name = @tableName)";
|
||
tableCheckCmd.Parameters.Add(new NpgsqlParameter("@tableName", request.TableName));
|
||
|
||
var tableExists = Convert.ToBoolean(await tableCheckCmd.ExecuteScalarAsync());
|
||
if (!tableExists)
|
||
{
|
||
return HypertableCreateResult.Failed($"테이블 '{request.TableName}'이 존재하지 않습니다.");
|
||
}
|
||
|
||
// 2. 이미 하이퍼테이블인지 확인
|
||
await using var hypertableCheckCmd = _ctx.Database.GetDbConnection().CreateCommand();
|
||
hypertableCheckCmd.CommandText = @"
|
||
SELECT EXISTS (
|
||
SELECT 1 FROM timescaledb_information.hypertables
|
||
WHERE hypertable_name = @tableName)";
|
||
hypertableCheckCmd.Parameters.Add(new NpgsqlParameter("@tableName", request.TableName));
|
||
|
||
var isHypertable = Convert.ToBoolean(await hypertableCheckCmd.ExecuteScalarAsync());
|
||
if (isHypertable)
|
||
{
|
||
return HypertableCreateResult.AlreadyExists($"테이블 '{request.TableName}'은 이미 하이퍼테이블입니다.");
|
||
}
|
||
|
||
// 3. TimescaleDB 확장 활성화
|
||
await _ctx.Database.ExecuteSqlRawAsync("CREATE EXTENSION IF NOT EXISTS timescaledb");
|
||
|
||
// 3-1. 기존 SERIAL PRIMARY KEY 제약사항 제거 (TimeScaleDB 호환성)
|
||
var dropPrimaryKeySql = $"ALTER TABLE {request.TableName} DROP CONSTRAINT IF EXISTS {request.TableName}_pkey";
|
||
Console.WriteLine($"[DEBUG] 기본키 제약사항 제거 SQL: {dropPrimaryKeySql}");
|
||
#pragma warning disable EF1002
|
||
await _ctx.Database.ExecuteSqlRawAsync(dropPrimaryKeySql);
|
||
#pragma warning restore EF1002
|
||
|
||
// 4. 하이퍼테이블 생성 (기존 데이터 마이그레이션 포함)
|
||
var createHypertableSql = $"SELECT create_hypertable('{request.TableName}'::regclass, '{request.TimeColumn}'::text, if_not_exists => TRUE, migrate_data => TRUE)";
|
||
Console.WriteLine($"[DEBUG] 하이퍼테이블 생성 SQL: {createHypertableSql}");
|
||
#pragma warning disable EF1002
|
||
await _ctx.Database.ExecuteSqlRawAsync(createHypertableSql);
|
||
#pragma warning restore EF1002
|
||
|
||
// 4-1. TimeScaleDB 하이퍼테이블에 적합한 새로운 기본키 생성
|
||
var addPrimaryKeySql = $"ALTER TABLE {request.TableName} ADD CONSTRAINT {request.TableName}_pkey PRIMARY KEY ({request.TimeColumn}, id)";
|
||
Console.WriteLine($"[DEBUG] 새 기본키 생성 SQL: {addPrimaryKeySql}");
|
||
#pragma warning disable EF1002
|
||
await _ctx.Database.ExecuteSqlRawAsync(addPrimaryKeySql);
|
||
#pragma warning restore EF1002
|
||
|
||
// 6. 보존 정책 설정 (요청된 경우)
|
||
if (request.SetRetentionPolicy && !string.IsNullOrEmpty(request.RetentionPeriod))
|
||
{
|
||
var retentionSql = $"SELECT add_retention_policy('{request.TableName}'::regclass, INTERVAL '{request.RetentionPeriod}')";
|
||
Console.WriteLine($"[DEBUG] 보존 정책 SQL: {retentionSql}");
|
||
#pragma warning disable EF1002
|
||
await _ctx.Database.ExecuteSqlRawAsync(retentionSql);
|
||
#pragma warning restore EF1002
|
||
}
|
||
|
||
// 7. 압축 정책 설정 (요청된 경우)
|
||
if (request.EnableCompression && !string.IsNullOrEmpty(request.CompressionPeriod))
|
||
{
|
||
var compressionSql = $"SELECT add_compression_policy('{request.TableName}'::regclass, INTERVAL '{request.CompressionPeriod}')";
|
||
Console.WriteLine($"[DEBUG] 압축 정책 SQL: {compressionSql}");
|
||
#pragma warning disable EF1002
|
||
await _ctx.Database.ExecuteSqlRawAsync(compressionSql);
|
||
#pragma warning restore EF1002
|
||
}
|
||
|
||
// 7-1. history_table 컬럼명 검증 (tag_name vs tagname)
|
||
if (request.TableName == "history_table")
|
||
{
|
||
await using var columnCheckCmd = _ctx.Database.GetDbConnection().CreateCommand();
|
||
columnCheckCmd.CommandText = @"
|
||
SELECT column_name FROM information_schema.columns
|
||
WHERE table_schema = 'public'
|
||
AND table_name = 'history_table'
|
||
AND column_name IN ('tag_name', 'tagname')
|
||
ORDER BY column_name;";
|
||
|
||
var columns = new List<string>();
|
||
await using var reader = await columnCheckCmd.ExecuteReaderAsync();
|
||
while (await reader.ReadAsync())
|
||
{
|
||
columns.Add(reader.GetString(0));
|
||
}
|
||
|
||
Console.WriteLine($"[DEBUG] history_table 컬럼명 검증: {string.Join(", ", columns)}");
|
||
}
|
||
|
||
// 8. 연속 집계 생성 (요청된 경우)
|
||
if (request.CreateContinuousAggregate)
|
||
{
|
||
// 8-1. 기존 MATERIALIZED VIEW 삭제 (별도 실행)
|
||
var dropViewSql = "DROP MATERIALIZED VIEW IF EXISTS history_5min_agg";
|
||
Console.WriteLine($"[DEBUG] 연속 집계 DROP VIEW SQL: {dropViewSql}");
|
||
#pragma warning disable EF1002
|
||
await _ctx.Database.ExecuteSqlRawAsync(dropViewSql);
|
||
#pragma warning restore EF1002
|
||
|
||
// 8-2. 연속 집계 MATERIALIZED VIEW 생성 (별도 실행)
|
||
var createViewSql = $@"
|
||
CREATE MATERIALIZED VIEW history_5min_agg
|
||
WITH (timescaledb.continuous) AS
|
||
SELECT
|
||
time_bucket(INTERVAL '5 minutes', {request.TimeColumn}) AS time_bucket,
|
||
tagname,
|
||
AVG(value) AS avg_value,
|
||
first(value, {request.TimeColumn}) AS min_value,
|
||
last(value, {request.TimeColumn}) AS max_value
|
||
FROM {request.TableName}
|
||
GROUP BY time_bucket, tagname;";
|
||
|
||
Console.WriteLine($"[DEBUG] 연속 집계 CREATE VIEW SQL: {createViewSql}");
|
||
|
||
#pragma warning disable EF1002
|
||
Console.WriteLine($"[DEBUG] CREATE VIEW SQL 실행 전: {createViewSql}");
|
||
await _ctx.Database.ExecuteSqlRawAsync(createViewSql);
|
||
Console.WriteLine($"[DEBUG] CREATE VIEW SQL 실행 성공");
|
||
#pragma warning restore EF1002
|
||
|
||
// 8-3. 연속 집계 정책 추가
|
||
var aggregatePolicySql = $"SELECT add_continuous_aggregate_policy('history_5min_agg', '10 minutes', '1 minute', '5 minutes')";
|
||
|
||
Console.WriteLine($"[DEBUG] 연속 집계 정책 SQL: {aggregatePolicySql}");
|
||
|
||
#pragma warning disable EF1002
|
||
await _ctx.Database.ExecuteSqlRawAsync(aggregatePolicySql);
|
||
#pragma warning restore EF1002
|
||
}
|
||
|
||
return HypertableCreateResult.Ok();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
var errorDetails = $"하이퍼테이블 생성 실패: {ex.Message}";
|
||
Console.WriteLine($"[ERROR] {errorDetails}");
|
||
if (ex.InnerException != null)
|
||
{
|
||
Console.WriteLine($"[ERROR] InnerException: {ex.InnerException.Message}");
|
||
Console.WriteLine($"[ERROR] InnerException StackTrace: {ex.InnerException.StackTrace}");
|
||
}
|
||
Console.WriteLine($"[ERROR] Full StackTrace: {ex.StackTrace}");
|
||
return HypertableCreateResult.Failed(errorDetails);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// SQL 식별자로 안전한지 검증합니다. 영문, 숫자, 언더스코어, 하이픈, 마침표만 허용합니다.
|
||
/// EF1002 SQL injection 방지를 위해 DDL 문에서 식별자를 사용할 때 이 메서드로 검증을 필수로 합니다.
|
||
/// </summary>
|
||
private static bool IsValidSqlIdentifier(string identifier)
|
||
{
|
||
if (string.IsNullOrEmpty(identifier))
|
||
return false;
|
||
|
||
if (identifier.Length > 63) // PostgreSQL 식별자 최대 길이
|
||
return false;
|
||
|
||
// 영문, 숫자, 언더스코어, 하이픈, 마침표만 허용
|
||
return System.Text.RegularExpressions.Regex.IsMatch(
|
||
identifier,
|
||
@"^[a-zA-Z0-9_\-\.]+$");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 하이퍼테이블 상태 정보 결과 클래스
|
||
/// </summary>
|
||
public record HypertableStatusInfo
|
||
{
|
||
public bool IsHypertable { get; init; }
|
||
public string? TableName { get; init; }
|
||
public string? StatusMessage { get; init; }
|
||
public int RecordCount { get; init; }
|
||
public bool HasRetentionPolicy { get; init; }
|
||
public bool HasCompression { get; init; }
|
||
public bool HasContinuousAggregate { get; init; }
|
||
}
|
||
|
||
/// <summary>
|
||
/// 하이퍼테이블 생성 요청 클래스
|
||
/// </summary>
|
||
public record HypertableCreateRequest
|
||
{
|
||
public string TableName { get; init; } = "history_table";
|
||
public string TimeColumn { get; init; } = "recorded_at";
|
||
public string TimeInterval { get; init; } = "1 day";
|
||
public bool MigrateData { get; init; } = true;
|
||
public bool SetRetentionPolicy { get; init; } = true;
|
||
public string RetentionPeriod { get; init; } = "90 days";
|
||
public bool EnableCompression { get; init; } = true;
|
||
public string CompressionPeriod { get; init; } = "1 day";
|
||
public bool CreateContinuousAggregate { get; init; } = true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 하이퍼테이블 생성 결과 클래스
|
||
/// </summary>
|
||
public record HypertableCreateResult
|
||
{
|
||
public bool Success { get; init; }
|
||
public string Message { get; init; } = string.Empty;
|
||
public string? TableName { get; init; }
|
||
|
||
public static HypertableCreateResult Ok(string? tableName = null, string? message = null)
|
||
=> new() { Success = true, TableName = tableName, Message = message ?? "하이퍼테이블이 성공적으로 생성되었습니다." };
|
||
|
||
public static HypertableCreateResult Failed(string message)
|
||
=> new() { Success = false, Message = message };
|
||
|
||
public static HypertableCreateResult AlreadyExists(string message)
|
||
=> new() { Success = false, Message = message };
|
||
}
|