diff --git a/ExcelKv.Core/ExcelKv.Core.csproj b/ExcelKv.Core/ExcelKv.Core.csproj index d75bbd4..6e6b952 100644 --- a/ExcelKv.Core/ExcelKv.Core.csproj +++ b/ExcelKv.Core/ExcelKv.Core.csproj @@ -9,6 +9,7 @@ - - - + + + + diff --git a/ExcelKv.Core/ExcelLoader.cs b/ExcelKv.Core/ExcelLoader.cs index c307ec2..baaa782 100644 --- a/ExcelKv.Core/ExcelLoader.cs +++ b/ExcelKv.Core/ExcelLoader.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using MiniExcelLibs; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Spreadsheet; namespace ExcelKv.Core; @@ -72,38 +74,46 @@ public class ExcelLoader Console.WriteLine($"[Core.Loader] Processing {filePath} Sheet: {sheetName}..."); var rows = MiniExcel.Query(filePath, sheetName: sheetName, useHeaderRow: false).ToList(); + var mergedRanges = GetMergedRanges(filePath, sheetName); // Validation int dataStartRow = config.TopHeaderStartRow + config.TopHeaderDepth; if (rows.Count <= dataStartRow) return 0; - // 1. Analyze Top Headers (Data Columns) - var topHeaderRows = new List(); - for (int i = config.TopHeaderStartRow; i < dataStartRow; i++) - { - topHeaderRows.Add(FlattenDictionaryRow((IDictionary)rows[i])); - } - - // Calculate Global Max Col from Data Rows to ensure we don't truncate data - // Optimization: check a sample or assume logical limit. For correctness, check valid rows. - int globalMaxCol = 0; int limitRow = config.DataEndRow.HasValue ? Math.Min(rows.Count, config.DataEndRow.Value + 1) : rows.Count; - for(int i = dataStartRow; i < limitRow; i++) + + // Flatten all rows up to limit for consistent indexing + var flattenedRows = new List(); + int globalMaxCol = 0; + for (int i = 0; i < limitRow; i++) { - var d = (IDictionary)rows[i]; - if(d.Count > globalMaxCol) globalMaxCol = d.Count; // Approximate - // FlattenDictionaryRow is cleaner but expensive to call just for count. - // d.Keys.Count is effectively the column count for that row. + var flat = FlattenDictionaryRow((IDictionary)rows[i]); + flattenedRows.Add(flat); + if (flat.Length > globalMaxCol) globalMaxCol = flat.Length; } + int maxMergeCol = mergedRanges.Any() ? mergedRanges.Max(m => m.EndCol + 1) : 0; + int normalizedMaxCol = Math.Max(globalMaxCol, maxMergeCol); + + ApplyMergedValues(flattenedRows, mergedRanges, normalizedMaxCol); + + // 1. Analyze Top Headers (Data Columns) + var topHeaderRows = flattenedRows + .Skip(config.TopHeaderStartRow) + .Take(config.TopHeaderDepth) + .ToList(); + + int headerMaxCol = topHeaderRows.Any() ? Math.Max(normalizedMaxCol, topHeaderRows.Max(r => r.Length)) : normalizedMaxCol; + var filledHeaders = topHeaderRows; + // Flatten Data Headers (Right Side) - var topAxisKeys = FlattenTopHeaders(topHeaderRows, config.LeftHeaderStartCol + config.LeftHeaderWidth, globalMaxCol); + var topAxisKeys = FlattenTopHeaders(filledHeaders, config.LeftHeaderStartCol + config.LeftHeaderWidth, headerMaxCol); // ... (Left Axis Header logic omitted for brevity, unchanged) ... // ** New: Extract Left Axis Headers (Corner Region) ** // These are the headers *above* the Left Key columns. var leftAxisHeaders = new List(); - var bottomHeaderRow = FlattenDictionaryRow((IDictionary)rows[dataStartRow - 1]); + var bottomHeaderRow = flattenedRows[dataStartRow - 1]; for (int c = 0; c < config.LeftHeaderWidth; c++) { @@ -122,8 +132,7 @@ public class ExcelLoader for (int i = dataStartRow; i < limitRow; i++) { - var rowDict = (IDictionary)rows[i]; - var rowVals = FlattenDictionaryRow(rowDict); + var rowVals = flattenedRows[i]; // ... (Left Key Logic Unchanged) ... var currentLeftParts = new List(); @@ -140,7 +149,7 @@ public class ExcelLoader } } if (!rowHasContent) continue; - string leftKey = string.Join("----", currentLeftParts); + string leftKey = string.Join("__", currentLeftParts); // B. Map Values int limitCol = config.DataEndCol.HasValue ? Math.Min(rowVals.Length, config.DataEndCol.Value + 1) : rowVals.Length; @@ -168,7 +177,7 @@ public class ExcelLoader // Update: User said "소수점 6자리까지 표현하는걸로 통일". Could mean truncate or round. Safe bet is Round. } - string fullKey = $"{sheetName}:{leftKey}*{topKey}"; + string fullKey = $"{sheetName}:{leftKey}----{topKey}"; await storage.SetAsync(fullKey, val, i, c); @@ -193,11 +202,43 @@ public class ExcelLoader for (int i = 0; i < sortedKeys.Count; i++) { var val = rowDict[sortedKeys[i]]; - result[i] = val?.ToString() ?? ""; + result[i] = ExtractCellValue(val); } return result; } + // Normalize cell value, preferring cached result for formulas. + private static string ExtractCellValue(object val) + { + if (val == null) return ""; + + string ToStr(object? o) => o?.ToString() ?? ""; + + var type = val.GetType(); + var typeName = type.FullName ?? ""; + + // MiniExcel may return an internal ExcelFormula type; try to read cached value. + if (typeName.Contains("Formula", StringComparison.OrdinalIgnoreCase)) + { + var cached = type.GetProperty("Value")?.GetValue(val) + ?? type.GetProperty("CachedValue")?.GetValue(val) + ?? type.GetProperty("Result")?.GetValue(val); + if (cached != null) return ToStr(cached); + } + + var s = ToStr(val); + if (s.StartsWith("=")) + { + var cached = type.GetProperty("CachedValue")?.GetValue(val) + ?? type.GetProperty("Value")?.GetValue(val) + ?? type.GetProperty("Result")?.GetValue(val); + if (cached != null) return ToStr(cached); + // If no cached value, avoid persisting the formula string. + return ""; + } + return s; + } + private static List FlattenTopHeaders(List headerRows, int startCol, int globalMaxCol) { if (headerRows.Count == 0) return new List(); @@ -206,8 +247,6 @@ public class ExcelLoader var flatHeaders = new List(); - // Iterate Columns - var lastValues = new string[headerRows.Count]; for (int c = startCol; c < maxCol; c++) { var parts = new List(); @@ -215,19 +254,102 @@ public class ExcelLoader for (int r = 0; r < headerRows.Count; r++) { string val = (c < headerRows[r].Length) ? headerRows[r][c] : ""; - - // Horizontal Forward Fill (Re-enabled for Merged Headers) - if (string.IsNullOrWhiteSpace(val)) val = lastValues[r]; - else lastValues[r] = val; - if (!string.IsNullOrWhiteSpace(val)) parts.Add(val); } // If empty, use "Col_Index" fallback or keep empty? // User schema usually requires keys. If empty, it's skipped in mapping. // Let's keep it empty, but if data exists, it won't map unless we have a key. // If parts is empty, let's leave valid empty string so mapping can decide. - flatHeaders.Add(string.Join("----", parts)); + flatHeaders.Add(string.Join("__", parts)); } return flatHeaders; } + + private record MergeRange(int StartRow, int EndRow, int StartCol, int EndCol); + + private static List GetMergedRanges(string filePath, string sheetName) + { + var result = new List(); + try + { + using var doc = SpreadsheetDocument.Open(filePath, false); + var sheet = doc.WorkbookPart?.Workbook.Descendants().FirstOrDefault(s => s.Name == sheetName); + if (sheet == null) return result; + + var wsPart = doc.WorkbookPart?.GetPartById(sheet.Id!) as WorksheetPart; + var mergeCells = wsPart?.Worksheet.Elements().FirstOrDefault(); + if (mergeCells == null) return result; + + foreach (var mc in mergeCells.Elements()) + { + var (sr, er, sc, ec) = ParseRange(mc.Reference?.Value ?? ""); + result.Add(new MergeRange(sr, er, sc, ec)); + } + } + catch + { + // If merge metadata cannot be read, fall back to no-op. + } + return result; + } + + private static (int startRow, int endRow, int startCol, int endCol) ParseRange(string reference) + { + if (string.IsNullOrWhiteSpace(reference)) return (0, 0, 0, 0); + if (!reference.Contains(":")) + { + var (r, c) = ParseCell(reference); + return (r, r, c, c); + } + var parts = reference.Split(':'); + var (r1, c1) = ParseCell(parts[0]); + var (r2, c2) = ParseCell(parts[1]); + return (Math.Min(r1, r2), Math.Max(r1, r2), Math.Min(c1, c2), Math.Max(c1, c2)); + } + + private static (int row, int col) ParseCell(string cellRef) + { + int row = 0, col = 0; + int i = 0; + while (i < cellRef.Length && char.IsLetter(cellRef[i])) + { + col = col * 26 + (char.ToUpperInvariant(cellRef[i]) - 'A' + 1); + i++; + } + string rowStr = cellRef[i..]; + int.TryParse(rowStr, out row); + // Convert to zero-based indices + return (Math.Max(0, row - 1), Math.Max(0, col - 1)); + } + + private static void ApplyMergedValues(List rows, List merges, int maxCols) + { + NormalizeRows(rows, maxCols); + foreach (var merge in merges) + { + if (merge.StartRow >= rows.Count) continue; + string anchor = (merge.StartCol < rows[merge.StartRow].Length) ? rows[merge.StartRow][merge.StartCol] : ""; + for (int r = merge.StartRow; r <= merge.EndRow && r < rows.Count; r++) + { + var row = rows[r]; + for (int c = merge.StartCol; c <= merge.EndCol && c < row.Length; c++) + { + row[c] = anchor; + } + } + } + } + + private static void NormalizeRows(List rows, int maxCols) + { + for (int i = 0; i < rows.Count; i++) + { + if (rows[i].Length < maxCols) + { + var padded = new string[maxCols]; + Array.Copy(rows[i], padded, rows[i].Length); + rows[i] = padded; + } + } + } } diff --git a/ExcelKvPoC/Program.cs b/ExcelKvPoC/Program.cs index 12c35c3..f4de758 100644 --- a/ExcelKvPoC/Program.cs +++ b/ExcelKvPoC/Program.cs @@ -1,21 +1,12 @@ -using System; -using System.IO; -using System.Collections.Generic; -using System.Linq; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using Garnet; -using MiniExcelLibs; - -using System; -using System.IO; -using System.Collections.Generic; -using System.Linq; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using Garnet; -using MiniExcelLibs; -using ExcelKv.Core; // Use Shared Library +using System; +using System.IO; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Garnet; +using MiniExcelLibs; +using ExcelKv.Core; // Use Shared Library namespace ExcelKvPoC; @@ -29,17 +20,24 @@ public class GarnetClientAdapter : IStorageWrapper, IDisposable private readonly List _tasks = new(); public GarnetClientAdapter(string connectionString = "localhost:3278") - { - _redis = StackExchange.Redis.ConnectionMultiplexer.Connect(connectionString); - _db = _redis.GetDatabase(); - _batch = _db.CreateBatch(); - } - - public async Task SetAsync(string key, string value) - { - _tasks.Add(_batch.StringSetAsync(key, value)); - await Task.CompletedTask; // Fire and forget in batch context mainly - } + { + _redis = StackExchange.Redis.ConnectionMultiplexer.Connect(connectionString); + _db = _redis.GetDatabase(); + _batch = _db.CreateBatch(); + } + + public async Task SetAsync(string key, string value) + { + _tasks.Add(_batch.StringSetAsync(key, value)); + await Task.CompletedTask; // Fire and forget in batch context mainly + } + + public async Task SetAsync(string key, string value, int row, int col) + { + // For this adapter we store only key/value; row/col metadata can be added later if needed. + _tasks.Add(_batch.StringSetAsync(key, value)); + await Task.CompletedTask; + } public async Task IncrementAsync(string key, double value) { diff --git a/README.md b/README.md index e0a01c3..80eca1e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ - **Embedded Garnet**: Redis 호환 고성능 인메모리 스토어 내장. - **2D Flattening**: - 병합된 Cross-Tab 엑셀 헤더를 논리적 Key로 자동 평탄화. - - Key Format: `{Sheet}:{LeftAxis}*{TopAxis}` (예: `Station.No1*Concrete.TypeA`) + - Key Format: `{Sheet}:{LeftAxis}----{TopAxis}` (예: `Station__No1----Concrete__.__TypeA`) ### 2. Smart Schema Management - **Interactive Region**: 헤더와 데이터 영역을 동적으로 지정 가능 (`RegionConfig`). diff --git a/SchemaEditor/Services/GarnetClientService.cs b/SchemaEditor/Services/GarnetClientService.cs index 6580c34..8579dc8 100644 --- a/SchemaEditor/Services/GarnetClientService.cs +++ b/SchemaEditor/Services/GarnetClientService.cs @@ -9,10 +9,10 @@ namespace SchemaEditor.Services; public class GarnetClientService : IStorageWrapper, IDisposable { - private ConnectionMultiplexer _redis; - private IDatabase _db; + private ConnectionMultiplexer? _redis; + private IDatabase? _db; - public bool IsConnected => _redis != null && _redis.IsConnected; + public bool IsConnected => _redis?.IsConnected == true; public void Connect(string connectionString = "localhost:3187") { @@ -26,7 +26,7 @@ public class GarnetClientService : IStorageWrapper, IDisposable public async Task SetAsync(string key, string value) { if (_db == null) Connect(); - await _db.StringSetAsync(key, value); + if (_db != null) await _db.StringSetAsync(key, value); } public async Task SetAsync(string key, string value, int row, int col) @@ -35,19 +35,21 @@ public class GarnetClientService : IStorageWrapper, IDisposable // Traceability metadata (row, col) could be stored in a hash or side key if needed. // For now, we just proceed with standard storage. if (_db == null) Connect(); - await _db.StringSetAsync(key, value); + if (_db != null) await _db.StringSetAsync(key, value); } public async Task IncrementAsync(string key, double value) { if (_db == null) Connect(); - await _db.StringIncrementAsync(key, value); + if (_db != null) await _db.StringIncrementAsync(key, value); } // For Data Explorer public async Task> SearchKeysAsync(string pattern) { if (_db == null) Connect(); + if (_redis == null || _db == null) return await Task.FromResult(new List()); + var server = _redis.GetServer(_redis.GetEndPoints().First()); // Use Keys for simplicity in Schema Editor (low traffic) // In high production, use SCAN @@ -58,7 +60,9 @@ public class GarnetClientService : IStorageWrapper, IDisposable public async Task GetValueAsync(string key) { if (_db == null) Connect(); - return await _db.StringGetAsync(key); + if (_db == null) return string.Empty; + var val = await _db.StringGetAsync(key); + return val.HasValue ? val.ToString() : string.Empty; } public void Dispose() diff --git a/SchemaEditor/Services/GarnetHost.cs b/SchemaEditor/Services/GarnetHost.cs index 5401401..dcf454e 100644 --- a/SchemaEditor/Services/GarnetHost.cs +++ b/SchemaEditor/Services/GarnetHost.cs @@ -11,18 +11,18 @@ public class GarnetHost : IHostedService, IDisposable private GarnetServer? _server; public Task StartAsync(CancellationToken cancellationToken) - { - try { - var serverArgs = new string[] { "--port", "3187" }; // Changed to 3187 - _server = new GarnetServer(serverArgs); - _server.Start(); - Console.WriteLine("[GarnetHost] Server started on port 3278"); - } - catch (Exception ex) - { - Console.WriteLine($"[GarnetHost] Failed to start: {ex.Message}"); - } + try + { + var serverArgs = new string[] { "--port", "3187" }; // Changed to 3187 + _server = new GarnetServer(serverArgs); + _server.Start(); + Console.WriteLine("[GarnetHost] Server started on port 3187"); + } + catch (Exception ex) + { + Console.WriteLine($"[GarnetHost] Failed to start: {ex.Message}"); + } return Task.CompletedTask; }