Skip to content

Commit 171aeed

Browse files
authored
feat: Auto-generate DbContext names from SQL Project, DACPAC, or connection string (#24)
1 parent e623f5b commit 171aeed

6 files changed

Lines changed: 1006 additions & 2 deletions

File tree

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
using System.Text;
2+
using System.Text.RegularExpressions;
3+
4+
namespace JD.Efcpt.Build.Tasks;
5+
6+
/// <summary>
7+
/// Generates DbContext names from SQL projects, DACPACs, or connection strings.
8+
/// </summary>
9+
/// <remarks>
10+
/// <para>
11+
/// This class provides logic to automatically derive a meaningful DbContext name from various sources:
12+
/// <list type="bullet">
13+
/// <item><description>SQL Project: Uses the project file name (e.g., "Database.csproj" → "DatabaseContext")</description></item>
14+
/// <item><description>DACPAC: Uses the DACPAC filename with special characters removed (e.g., "Our_Database20251225.dacpac" → "OurDatabaseContext")</description></item>
15+
/// <item><description>Connection String: Extracts the database name (e.g., "Database=MyDb" → "MyDbContext")</description></item>
16+
/// </list>
17+
/// </para>
18+
/// <para>
19+
/// All names are humanized by:
20+
/// <list type="bullet">
21+
/// <item><description>Removing file extensions</description></item>
22+
/// <item><description>Removing non-letter characters except underscores (replaced with empty string)</description></item>
23+
/// <item><description>Converting PascalCase (handling underscores as word boundaries)</description></item>
24+
/// <item><description>Appending "Context" suffix if not already present</description></item>
25+
/// </list>
26+
/// </para>
27+
/// </remarks>
28+
public static partial class DbContextNameGenerator
29+
{
30+
private const string DefaultContextName = "MyDbContext";
31+
private const string ContextSuffix = "Context";
32+
33+
/// <summary>
34+
/// Generates a DbContext name from the provided SQL project path.
35+
/// </summary>
36+
/// <param name="sqlProjPath">Full path to the SQL project file</param>
37+
/// <returns>Generated context name or null if unable to resolve</returns>
38+
/// <example>
39+
/// <code>
40+
/// var name = DbContextNameGenerator.FromSqlProject("/path/to/Database.csproj");
41+
/// // Returns: "DatabaseContext"
42+
///
43+
/// var name = DbContextNameGenerator.FromSqlProject("/path/to/Org.Unit.SystemData.sqlproj");
44+
/// // Returns: "SystemDataContext"
45+
/// </code>
46+
/// </example>
47+
public static string? FromSqlProject(string? sqlProjPath)
48+
{
49+
if (string.IsNullOrWhiteSpace(sqlProjPath))
50+
return null;
51+
52+
try
53+
{
54+
var fileName = GetFileNameWithoutExtension(sqlProjPath);
55+
return HumanizeName(fileName);
56+
}
57+
catch
58+
{
59+
return null;
60+
}
61+
}
62+
63+
/// <summary>
64+
/// Generates a DbContext name from the provided DACPAC file path.
65+
/// </summary>
66+
/// <param name="dacpacPath">Full path to the DACPAC file</param>
67+
/// <returns>Generated context name or null if unable to resolve</returns>
68+
/// <example>
69+
/// <code>
70+
/// var name = DbContextNameGenerator.FromDacpac("/path/to/Our_Database20251225.dacpac");
71+
/// // Returns: "OurDatabaseContext"
72+
///
73+
/// var name = DbContextNameGenerator.FromDacpac("/path/to/MyDb.dacpac");
74+
/// // Returns: "MyDbContext"
75+
/// </code>
76+
/// </example>
77+
public static string? FromDacpac(string? dacpacPath)
78+
{
79+
if (string.IsNullOrWhiteSpace(dacpacPath))
80+
return null;
81+
82+
try
83+
{
84+
var fileName = GetFileNameWithoutExtension(dacpacPath);
85+
return HumanizeName(fileName);
86+
}
87+
catch
88+
{
89+
return null;
90+
}
91+
}
92+
93+
/// <summary>
94+
/// Extracts the filename without extension from a path, handling both Unix and Windows paths.
95+
/// </summary>
96+
/// <param name="path">The file path</param>
97+
/// <returns>The filename without extension</returns>
98+
private static string GetFileNameWithoutExtension(string path)
99+
{
100+
// Handle both Unix (/) and Windows (\) path separators
101+
var lastSlash = Math.Max(path.LastIndexOf('/'), path.LastIndexOf('\\'));
102+
var fileName = lastSlash >= 0 ? path.Substring(lastSlash + 1) : path;
103+
104+
// Remove extension
105+
var lastDot = fileName.LastIndexOf('.');
106+
if (lastDot >= 0)
107+
{
108+
fileName = fileName.Substring(0, lastDot);
109+
}
110+
111+
return fileName;
112+
}
113+
114+
/// <summary>
115+
/// Generates a DbContext name from the provided connection string.
116+
/// </summary>
117+
/// <param name="connectionString">Database connection string</param>
118+
/// <returns>Generated context name or null if unable to resolve</returns>
119+
/// <example>
120+
/// <code>
121+
/// var name = DbContextNameGenerator.FromConnectionString(
122+
/// "Server=myServerAddress;Database=myDataBase;User Id=myUsername;Password=myPassword;");
123+
/// // Returns: "MyDataBaseContext"
124+
///
125+
/// var name = DbContextNameGenerator.FromConnectionString(
126+
/// "Data Source=sample.db");
127+
/// // Returns: "SampleContext" (from filename if Database keyword not found)
128+
/// </code>
129+
/// </example>
130+
public static string? FromConnectionString(string? connectionString)
131+
{
132+
if (string.IsNullOrWhiteSpace(connectionString))
133+
return null;
134+
135+
try
136+
{
137+
// Try to extract database name using various patterns
138+
var dbName = TryExtractDatabaseName(connectionString);
139+
if (!string.IsNullOrWhiteSpace(dbName))
140+
return HumanizeName(dbName);
141+
142+
return null;
143+
}
144+
catch
145+
{
146+
return null;
147+
}
148+
}
149+
150+
/// <summary>
151+
/// Generates a DbContext name using multiple strategies in priority order.
152+
/// </summary>
153+
/// <param name="sqlProjPath">Optional SQL project path</param>
154+
/// <param name="dacpacPath">Optional DACPAC file path</param>
155+
/// <param name="connectionString">Optional connection string</param>
156+
/// <returns>Generated context name or the default "MyDbContext" if unable to resolve</returns>
157+
/// <remarks>
158+
/// Priority order:
159+
/// 1. SQL Project name
160+
/// 2. DACPAC filename
161+
/// 3. Connection string database name
162+
/// 4. Default "MyDbContext"
163+
/// </remarks>
164+
public static string Generate(
165+
string? sqlProjPath,
166+
string? dacpacPath,
167+
string? connectionString)
168+
{
169+
// Priority 1: SQL Project
170+
var name = FromSqlProject(sqlProjPath);
171+
if (!string.IsNullOrWhiteSpace(name))
172+
return name;
173+
174+
// Priority 2: DACPAC
175+
name = FromDacpac(dacpacPath);
176+
if (!string.IsNullOrWhiteSpace(name))
177+
return name;
178+
179+
// Priority 3: Connection String
180+
name = FromConnectionString(connectionString);
181+
if (!string.IsNullOrWhiteSpace(name))
182+
return name;
183+
184+
// Fallback: Default name
185+
return DefaultContextName;
186+
}
187+
188+
/// <summary>
189+
/// Humanizes a raw name into a proper DbContext name.
190+
/// </summary>
191+
/// <param name="rawName">The raw name to humanize</param>
192+
/// <returns>Humanized context name</returns>
193+
/// <remarks>
194+
/// Process:
195+
/// 1. Handle dotted namespaces by taking the last segment (e.g., "Org.Unit.SystemData" → "SystemData")
196+
/// 2. Remove trailing digits (e.g., "Database20251225" → "Database")
197+
/// 3. Split on underscores/hyphens and capitalize each part
198+
/// 4. Remove all non-letter characters
199+
/// 5. Ensure PascalCase
200+
/// 6. Append "Context" suffix if not already present
201+
/// </remarks>
202+
private static string HumanizeName(string rawName)
203+
{
204+
if (string.IsNullOrWhiteSpace(rawName))
205+
return DefaultContextName;
206+
207+
// Handle dotted namespaces (e.g., "Org.Unit.SystemData" → "SystemData")
208+
var dotParts = rawName.Split('.', StringSplitOptions.RemoveEmptyEntries);
209+
var baseName = dotParts.Length > 0 ? dotParts[^1] : rawName;
210+
211+
// Remove digits at the end (common in DACPAC names like "MyDb20251225.dacpac")
212+
var nameWithoutTrailingDigits = TrailingDigitsRegex().Replace(baseName, "");
213+
if (string.IsNullOrWhiteSpace(nameWithoutTrailingDigits))
214+
nameWithoutTrailingDigits = baseName; // Keep original if only digits
215+
216+
// Split on underscores/hyphens and capitalize each part, then join
217+
var parts = nameWithoutTrailingDigits
218+
.Split(['_', '-'], StringSplitOptions.RemoveEmptyEntries)
219+
.Select(ToPascalCase)
220+
.ToArray();
221+
222+
if (parts.Length == 0)
223+
return DefaultContextName;
224+
225+
// Join all parts together (e.g., "sample_db" → "SampleDb")
226+
var joined = string.Concat(parts);
227+
228+
// Remove any remaining non-letter characters
229+
var cleaned = NonLetterRegex().Replace(joined, "");
230+
231+
if (string.IsNullOrWhiteSpace(cleaned) || cleaned.Length == 0)
232+
return DefaultContextName;
233+
234+
// Ensure it starts with uppercase
235+
cleaned = cleaned.Length == 1
236+
? char.ToUpperInvariant(cleaned[0]).ToString()
237+
: char.ToUpperInvariant(cleaned[0]) + cleaned[1..];
238+
239+
// Add "Context" suffix if not already present
240+
if (!cleaned.EndsWith(ContextSuffix, StringComparison.OrdinalIgnoreCase))
241+
cleaned += ContextSuffix;
242+
243+
return cleaned;
244+
}
245+
246+
/// <summary>
247+
/// Converts a string to PascalCase.
248+
/// </summary>
249+
private static string ToPascalCase(string input)
250+
{
251+
if (string.IsNullOrWhiteSpace(input) || input.Length == 0)
252+
return string.Empty;
253+
254+
// If already PascalCase or single word, just ensure first letter is uppercase
255+
if (!input.Contains(' ') && !input.Contains('-'))
256+
{
257+
return input.Length == 1
258+
? char.ToUpperInvariant(input[0]).ToString()
259+
: char.ToUpperInvariant(input[0]) + input[1..];
260+
}
261+
262+
// Split on spaces or hyphens and capitalize each word
263+
var words = input.Split([' ', '-'], StringSplitOptions.RemoveEmptyEntries);
264+
var result = new StringBuilder();
265+
266+
foreach (var word in words)
267+
{
268+
if (word.Length > 0)
269+
{
270+
result.Append(char.ToUpperInvariant(word[0]));
271+
if (word.Length > 1)
272+
result.Append(word[1..]);
273+
}
274+
}
275+
276+
return result.ToString();
277+
}
278+
279+
/// <summary>
280+
/// Attempts to extract the database name from a connection string.
281+
/// </summary>
282+
/// <param name="connectionString">The connection string</param>
283+
/// <returns>Database name if found, otherwise null</returns>
284+
private static string? TryExtractDatabaseName(string connectionString)
285+
{
286+
// Try "Database=" pattern (SQL Server, PostgreSQL, MySQL)
287+
var match = DatabaseKeywordRegex().Match(connectionString);
288+
if (match.Success)
289+
return match.Groups["name"].Value.Trim();
290+
291+
// Try "Initial Catalog=" pattern (SQL Server)
292+
match = InitialCatalogKeywordRegex().Match(connectionString);
293+
if (match.Success)
294+
return match.Groups["name"].Value.Trim();
295+
296+
// Try "Data Source=" for SQLite (extract filename without path and extension)
297+
match = DataSourceKeywordRegex().Match(connectionString);
298+
if (match.Success)
299+
{
300+
var dataSource = match.Groups["name"].Value.Trim();
301+
// If it's a file path (contains / or \) or file with extension, extract just the filename without extension
302+
if (dataSource.Contains('/') ||
303+
dataSource.Contains('\\') ||
304+
dataSource.Contains('.'))
305+
{
306+
// Handle both Unix and Windows paths
307+
var fileName = dataSource;
308+
var lastSlash = Math.Max(dataSource.LastIndexOf('/'), dataSource.LastIndexOf('\\'));
309+
if (lastSlash >= 0)
310+
{
311+
fileName = dataSource.Substring(lastSlash + 1);
312+
}
313+
314+
// Remove extension if present
315+
var lastDot = fileName.LastIndexOf('.');
316+
if (lastDot >= 0)
317+
{
318+
fileName = fileName.Substring(0, lastDot);
319+
}
320+
321+
return fileName;
322+
}
323+
// Plain database name without path or extension
324+
return dataSource;
325+
}
326+
327+
return null;
328+
}
329+
330+
[GeneratedRegex(@"[^a-zA-Z]", RegexOptions.Compiled)]
331+
private static partial Regex NonLetterRegex();
332+
333+
[GeneratedRegex(@"\d+$", RegexOptions.Compiled)]
334+
private static partial Regex TrailingDigitsRegex();
335+
336+
[GeneratedRegex(@"(?:Database|Db)\s*=\s*(?<name>[^;]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
337+
private static partial Regex DatabaseKeywordRegex();
338+
339+
[GeneratedRegex(@"Initial\s+Catalog\s*=\s*(?<name>[^;]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
340+
private static partial Regex InitialCatalogKeywordRegex();
341+
342+
[GeneratedRegex(@"Data\s+Source\s*=\s*(?<name>[^;]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
343+
private static partial Regex DataSourceKeywordRegex();
344+
}

0 commit comments

Comments
 (0)