Files
ExperionCrawler/.rooBackup/2026-05-07-161600/src/Infrastructure/Database/ExperionDbContext.cs
2026-05-08 17:22:10 +09:00

1385 lines
58 KiB
C#
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}