Skip to content

Commit 181eecc

Browse files
author
偉哲 林
committed
feat: add CSV export and fix ETL script filename logic
1 parent 7eaf3b9 commit 181eecc

File tree

8 files changed

+175
-12
lines changed

8 files changed

+175
-12
lines changed

Components/App.razor

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,6 @@
1313
<Routes @rendermode="@RenderMode.InteractiveServer" />
1414
<script src="_framework/blazor.web.js"></script>
1515
<script src="js/graphVisualizer.js"></script>
16+
<script src="js/download.js"></script>
1617
</body>
1718
</html>

Components/Pages/Home.razor

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
@inject SchemaReaderService SchemaReader
55
@inject LlmAnalysisService LlmAnalysis
66
@inject GraphModelService GraphService
7+
@inject CsvExportService CsvExport
78
@inject IJSRuntime JS
89

910
<div class="home-container">
@@ -169,6 +170,19 @@
169170
}
170171
else if (activeTab == "etl")
171172
{
173+
<div class="code-actions" style="margin-bottom: 10px; display: flex; gap: 10px;">
174+
<button class="btn btn-secondary btn-sm" @onclick="DownloadCsvZip" disabled="@isExporting">
175+
@if (isExporting)
176+
{
177+
<span class="spinner-sm"></span>
178+
<span>打包中...</span>
179+
}
180+
else
181+
{
182+
<span>📥 下載 CSV 資料包 (.zip)</span>
183+
}
184+
</button>
185+
</div>
172186
<pre><code>@analysisResult.CypherETL</code></pre>
173187
}
174188
else
@@ -191,7 +205,8 @@
191205
private bool isConnected = false;
192206
private bool isTestingConnection = false;
193207
private bool isAnalyzing = false;
194-
private bool isProcessing => isTestingConnection || isAnalyzing;
208+
private bool isExporting = false;
209+
private bool isProcessing => isTestingConnection || isAnalyzing || isExporting;
195210
private int currentStep = 1;
196211
private bool includeSampleData = true;
197212
private string analysisStatus = "分析中,請稍候...";
@@ -276,6 +291,32 @@
276291
selectedTable = selectedTable == tableName ? null : tableName;
277292
}
278293

294+
private async Task DownloadCsvZip()
295+
{
296+
if (sqlSchema == null || sqlSchema.Tables.Count == 0) return;
297+
298+
try
299+
{
300+
isExporting = true;
301+
StateHasChanged();
302+
303+
using var stream = await CsvExport.ExportTablesToZipAsync(connectionString, sqlSchema.Tables);
304+
var fileName = $"neo4j_import_{DateTime.Now:yyyyMMddHHmmss}.zip";
305+
306+
using var streamRef = new DotNetStreamReference(stream);
307+
await JS.InvokeVoidAsync("downloadFileFromStream", fileName, streamRef);
308+
}
309+
catch (Exception ex)
310+
{
311+
await JS.InvokeVoidAsync("alert", $"匯出失敗: {ex.Message}");
312+
}
313+
finally
314+
{
315+
isExporting = false;
316+
StateHasChanged();
317+
}
318+
}
319+
279320
private async Task CopyToClipboard()
280321
{
281322
var content = activeTab switch

Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
builder.Services.AddScoped<SchemaReaderService>();
1212
builder.Services.AddScoped<LlmAnalysisService>();
1313
builder.Services.AddScoped<GraphModelService>();
14+
builder.Services.AddScoped<CsvExportService>();
1415

1516
var app = builder.Build();
1617

README.md

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
- **LLM 智能分析** — 使用 Azure OpenAI 理解語意,產生最佳 Graph Model
1313
- **Sample Data 推斷** — 即使沒有 FK 定義,也能從資料內容推斷隱藏關係
1414
- **互動式視覺化** — 力導向圖,節點可拖拉,連線會 highlight
15+
- **一鍵 CSV 匯出** — 自動打包 CSV 資料檔,支援大檔案下載,簡化 Neo4j 匯入流程
1516
- **自動產生 Cypher** — DDL (Constraints/Index) + ETL (資料遷移腳本)
1617
- **中文支援** — 描述和推理解釋使用繁體中文
1718

@@ -21,6 +22,12 @@
2122

2223
![無 FK 推斷展示](docs/withoutFK.png)
2324

25+
## 📊 Neo4j 匯入成果與驗證
26+
27+
下圖為實際匯入 Neo4j 後的查詢結果 (`MATCH (n) RETURN n`)。可以看到即使來源資料庫**完全沒有 Foreign Key**,透過 SQL2Graph 推斷出的 Graph Model 依然建立了正確的關聯(如 `WORKS_ON`, `HAS_LEADER`, `REPORTS_TO`):
28+
29+
![Neo4j 匯入成果](docs/neo4J.png)
30+
2431
---
2532

2633
## 🔬 與傳統工具的技術比較
@@ -35,6 +42,7 @@
3542
| **解釋設計決策** || ✅ 推理面板 |
3643
| 視覺化預覽 | ⚠️ 需另開工具 | ✅ 內建力導向圖 |
3744
| 產生 Cypher DDL/ETL |||
45+
| **資料匯出** | ⚠️ 手動或複雜配置 |**一鍵打包 CSV** |
3846

3947
### 技術實現差異
4048

@@ -78,7 +86,7 @@ copy appsettings.template.json appsettings.json
7886
dotnet run
7987
```
8088

81-
打開瀏覽器:`http://localhost:5000`
89+
打開瀏覽器:`http://localhost:5050` (預設)
8290

8391
### 3. 連接資料庫並分析
8492

@@ -87,6 +95,16 @@ dotnet run
8795
3. 點擊「開始分析」
8896
4. 查看 Graph Model 視覺化和 Cypher 腳本
8997

98+
### 4. 匯出與遷移
99+
100+
1. 切換至「ETL」分頁
101+
2. 點擊「📥 下載 CSV 資料包」
102+
3. 將 ZIP 解壓縮並複製 CSV 至 Neo4j Import 目錄:
103+
```bash
104+
docker cp ./extracted_files/. neo4j:/var/lib/neo4j/import/
105+
```
106+
4. 複製並在 Neo4j Browser (`http://localhost:7474`) 執行 ETL Cypher 腳本
107+
90108
---
91109

92110
## 🔧 使用產生的 Cypher
@@ -100,12 +118,6 @@ docker run -d --name neo4j \
100118
neo4j:latest
101119
```
102120

103-
### 執行遷移
104-
105-
1. 在 Neo4j Browser (`http://localhost:7474`) 執行 DDL 腳本
106-
2. 從 MSSQL 匯出 CSV
107-
3. 執行 ETL 腳本匯入資料
108-
109121
---
110122

111123
## 🏗️ 技術架構
@@ -129,14 +141,17 @@ SQL2Graph/
129141
├── Services/
130142
│ ├── SchemaReaderService.cs # 讀取 MSSQL Schema + Sample Data
131143
│ ├── LlmAnalysisService.cs # LLM 分析
132-
│ └── GraphModelService.cs # Cypher 產生
144+
│ ├── GraphModelService.cs # Cypher 產生
145+
│ └── CsvExportService.cs # CSV 打包下載
133146
├── Models/
134147
│ ├── SqlSchema.cs # SQL 結構定義
135148
│ └── GraphModel.cs # Graph 結構定義
136149
├── wwwroot/
137150
│ ├── app.css # 樣式
138151
│ ├── guide.html # Graph DB 教學指南
139-
│ └── js/graphVisualizer.js # Cytoscape.js 封裝
152+
│ └── js/
153+
│ ├── graphVisualizer.js # Cytoscape.js 封裝
154+
│ └── download.js # 檔案下載 Helper
140155
└── docs/
141156
└── withoutFK.png # 展示截圖
142157
```

Services/CsvExportService.cs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
using System.IO.Compression;
2+
using System.Text;
3+
using Microsoft.Data.SqlClient;
4+
using SQL2Graph.Models;
5+
6+
namespace SQL2Graph.Services;
7+
8+
public class CsvExportService
9+
{
10+
private readonly ILogger<CsvExportService> _logger;
11+
12+
public CsvExportService(ILogger<CsvExportService> logger)
13+
{
14+
_logger = logger;
15+
}
16+
17+
public async Task<Stream> ExportTablesToZipAsync(string connectionString, List<TableInfo> tables)
18+
{
19+
var memoryStream = new MemoryStream();
20+
21+
using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true))
22+
{
23+
foreach (var table in tables)
24+
{
25+
var entryName = $"{table.SchemaName}_{table.TableName}.csv";
26+
var entry = archive.CreateEntry(entryName);
27+
28+
using var entryStream = entry.Open();
29+
using var writer = new StreamWriter(entryStream, new UTF8Encoding(false)); // UTF-8 without BOM
30+
31+
try
32+
{
33+
await WriteTableToCsvAsync(connectionString, table, writer);
34+
}
35+
catch (Exception ex)
36+
{
37+
_logger.LogError(ex, "Error exporting table {TableName}", table.FullName);
38+
// Continue with other tables, or write error to CSV?
39+
// Let's just log and continue for now.
40+
}
41+
}
42+
}
43+
44+
memoryStream.Position = 0;
45+
return memoryStream;
46+
}
47+
48+
private async Task WriteTableToCsvAsync(string connectionString, TableInfo table, StreamWriter writer)
49+
{
50+
using var connection = new SqlConnection(connectionString);
51+
await connection.OpenAsync();
52+
53+
var query = $"SELECT * FROM {table.FullName}"; // Be mindful of SQL injection if TableInfo comes from untrusted source. Here it comes from SchemaReader which reads DB metadata.
54+
55+
using var command = new SqlCommand(query, connection);
56+
using var reader = await command.ExecuteReaderAsync();
57+
58+
// Write Headers
59+
var columnCount = reader.FieldCount;
60+
var headers = new string[columnCount];
61+
for (int i = 0; i < columnCount; i++)
62+
{
63+
headers[i] = EscapeCsv(reader.GetName(i));
64+
}
65+
await writer.WriteLineAsync(string.Join(",", headers));
66+
67+
// Write Rows
68+
while (await reader.ReadAsync())
69+
{
70+
var line = new string[columnCount];
71+
for (int i = 0; i < columnCount; i++)
72+
{
73+
var value = reader.IsDBNull(i) ? "" : reader.GetValue(i).ToString();
74+
line[i] = EscapeCsv(value);
75+
}
76+
await writer.WriteLineAsync(string.Join(",", line));
77+
}
78+
}
79+
80+
private string EscapeCsv(string? field)
81+
{
82+
if (string.IsNullOrEmpty(field)) return "";
83+
84+
if (field.Contains(",") || field.Contains("\"") || field.Contains("\n") || field.Contains("\r"))
85+
{
86+
// Escape quotes by doubling them
87+
return $"\"{field.Replace("\"", "\"\"")}\"";
88+
}
89+
90+
return field;
91+
}
92+
}

Services/GraphModelService.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@ public string GenerateCypherETL(GraphModel model, SqlSchema schema)
7474
foreach (var node in model.Nodes)
7575
{
7676
sb.AppendLine($"// Create {node.Label} nodes from {node.SourceTable}");
77-
sb.AppendLine($"LOAD CSV WITH HEADERS FROM 'file:///{node.SourceTable.Replace(".", "_")}.csv' AS row");
77+
var tableName = node.SourceTable.Contains(".") ? node.SourceTable.Replace(".", "_") : $"dbo_{node.SourceTable}";
78+
sb.AppendLine($"LOAD CSV WITH HEADERS FROM 'file:///{tableName}.csv' AS row");
7879
sb.Append($"CREATE (n:{node.Label} {{");
7980

8081
var propStrings = node.Properties.Select(p =>
@@ -97,7 +98,8 @@ public string GenerateCypherETL(GraphModel model, SqlSchema schema)
9798
var toKey = toNode.Properties.FirstOrDefault(p => p.IsKey)?.GraphProperty ?? "id";
9899

99100
sb.AppendLine($"// Create {rel.Type} relationships");
100-
sb.AppendLine($"LOAD CSV WITH HEADERS FROM 'file:///{rel.SourceTable.Replace(".", "_")}.csv' AS row");
101+
var relTableName = rel.SourceTable.Contains(".") ? rel.SourceTable.Replace(".", "_") : $"dbo_{rel.SourceTable}";
102+
sb.AppendLine($"LOAD CSV WITH HEADERS FROM 'file:///{relTableName}.csv' AS row");
101103
sb.AppendLine($"MATCH (from:{rel.FromNode} {{{fromKey}: row.from_id}})");
102104
sb.AppendLine($"MATCH (to:{rel.ToNode} {{{toKey}: row.to_id}})");
103105

docs/neo4J.png

265 KB
Loading

wwwroot/js/download.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
window.downloadFileFromStream = async (fileName, contentStreamReference) => {
2+
const arrayBuffer = await contentStreamReference.arrayBuffer();
3+
const blob = new Blob([arrayBuffer]);
4+
const url = URL.createObjectURL(blob);
5+
const anchorElement = document.createElement('a');
6+
anchorElement.href = url;
7+
anchorElement.download = fileName ?? '';
8+
anchorElement.click();
9+
anchorElement.remove();
10+
URL.revokeObjectURL(url);
11+
}

0 commit comments

Comments
 (0)