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;
}