Skip to content

Commit 007d0d1

Browse files
authored
chore: Add schema-based config generator for efcpt-config.json files (#60)
1 parent 1f494f6 commit 007d0d1

5 files changed

Lines changed: 652 additions & 47 deletions

File tree

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using System.Linq;
5+
using System.Net.Http;
6+
using System.Text.Json;
7+
using System.Text.Json.Nodes;
8+
using System.Threading.Tasks;
9+
10+
namespace JD.Efcpt.Build.Tasks.Config;
11+
12+
/// <summary>
13+
/// Generates efcpt-config.json from the EFCorePowerTools JSON schema.
14+
/// </summary>
15+
public static class EfcptConfigGenerator
16+
{
17+
private const string PrimarySchemaUrl = "https://raw.githubusercontent.com/ErikEJ/EFCorePowerTools/master/samples/efcpt-config.schema.json";
18+
private const string FallbackSchemaUrl = "https://raw.githubusercontent.com/JerrettDavis/JD.Efcpt.Build/refs/heads/main/lib/efcpt-config.schema.json";
19+
20+
/// <summary>
21+
/// Generates a default efcpt-config.json from a schema URL.
22+
/// </summary>
23+
/// <param name="schemaUrl">URL to the schema (optional, tries primary then fallback)</param>
24+
/// <param name="dbContextName">Optional custom DbContext name (default: "ApplicationDbContext")</param>
25+
/// <param name="rootNamespace">Optional custom root namespace (default: "EfcptProject")</param>
26+
/// <returns>Generated JSON string</returns>
27+
public static async Task<string> GenerateFromUrlAsync(
28+
string? schemaUrl = null,
29+
string? dbContextName = null,
30+
string? rootNamespace = null)
31+
{
32+
schemaUrl ??= await TryGetSchemaUrlAsync();
33+
34+
using var client = new HttpClient();
35+
var schemaJson = await client.GetStringAsync(schemaUrl);
36+
return GenerateFromSchema(schemaJson, dbContextName, rootNamespace, schemaUrl);
37+
}
38+
39+
/// <summary>
40+
/// Tries to fetch schema from primary URL, falling back to secondary if needed.
41+
/// </summary>
42+
private static async Task<string> TryGetSchemaUrlAsync()
43+
{
44+
using var client = new HttpClient();
45+
client.Timeout = TimeSpan.FromSeconds(5);
46+
47+
try
48+
{
49+
await client.GetStringAsync(PrimarySchemaUrl);
50+
return PrimarySchemaUrl;
51+
}
52+
catch
53+
{
54+
return FallbackSchemaUrl;
55+
}
56+
}
57+
58+
/// <summary>
59+
/// Generates a default efcpt-config.json from a local schema file.
60+
/// </summary>
61+
/// <param name="schemaPath">Path to the schema file</param>
62+
/// <param name="dbContextName">Optional custom DbContext name (default: "ApplicationDbContext")</param>
63+
/// <param name="rootNamespace">Optional custom root namespace (default: "EfcptProject")</param>
64+
/// <param name="schemaUrl">Optional schema URL to include in $schema property (default: primary schema URL)</param>
65+
/// <returns>Generated JSON string</returns>
66+
public static string GenerateFromFile(
67+
string schemaPath,
68+
string? dbContextName = null,
69+
string? rootNamespace = null,
70+
string? schemaUrl = null)
71+
{
72+
var schemaJson = File.ReadAllText(schemaPath);
73+
schemaUrl ??= PrimarySchemaUrl;
74+
return GenerateFromSchema(schemaJson, dbContextName, rootNamespace, schemaUrl);
75+
}
76+
77+
/// <summary>
78+
/// Generates a default efcpt-config.json from schema JSON string.
79+
/// </summary>
80+
/// <param name="schemaJson">The JSON schema as a string</param>
81+
/// <param name="dbContextName">Optional custom DbContext name (default: "ApplicationDbContext")</param>
82+
/// <param name="rootNamespace">Optional custom root namespace (default: "EfcptProject")</param>
83+
/// <param name="schemaUrl">Optional schema URL to include in $schema property (default: primary schema URL)</param>
84+
/// <returns>Generated JSON string</returns>
85+
public static string GenerateFromSchema(
86+
string schemaJson,
87+
string? dbContextName = null,
88+
string? rootNamespace = null,
89+
string? schemaUrl = null)
90+
{
91+
var schema = JsonNode.Parse(schemaJson);
92+
if (schema is null)
93+
throw new InvalidOperationException("Failed to parse schema JSON");
94+
95+
var config = new JsonObject();
96+
97+
// Add $schema property first
98+
schemaUrl ??= PrimarySchemaUrl;
99+
config["$schema"] = schemaUrl;
100+
101+
var definitions = schema["definitions"]?.AsObject();
102+
if (definitions is null)
103+
throw new InvalidOperationException("Schema does not contain definitions section");
104+
105+
// Process each top-level section - only required properties
106+
ProcessCodeGeneration(config, definitions);
107+
ProcessFileLayout(config, definitions);
108+
ProcessNames(config, definitions, dbContextName, rootNamespace);
109+
// Don't process TypeMappings as it's not required
110+
111+
// Serialize with indentation
112+
var options = new JsonSerializerOptions
113+
{
114+
WriteIndented = true,
115+
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
116+
};
117+
118+
return JsonSerializer.Serialize(config, options);
119+
}
120+
121+
private static void ProcessCodeGeneration(JsonObject config, JsonObject definitions)
122+
{
123+
var codeGenDef = definitions["CodeGeneration"]?.AsObject();
124+
if (codeGenDef is null) return;
125+
126+
var required = GetRequiredProperties(codeGenDef);
127+
var properties = codeGenDef["properties"]?.AsObject();
128+
if (properties is null) return;
129+
130+
var codeGenConfig = new JsonObject();
131+
132+
// Process only required properties
133+
foreach (var propName in required)
134+
{
135+
// Skip preview properties
136+
if (propName.Contains("-preview", StringComparison.OrdinalIgnoreCase))
137+
continue;
138+
139+
var propDef = properties[propName]?.AsObject();
140+
if (propDef is null) continue;
141+
142+
if (TryGetDefaultValue(propDef, propName, out var defaultValue))
143+
{
144+
codeGenConfig[propName] = defaultValue;
145+
}
146+
}
147+
148+
if (codeGenConfig.Count > 0)
149+
{
150+
config["code-generation"] = codeGenConfig;
151+
}
152+
}
153+
154+
private static void ProcessNames(
155+
JsonObject config,
156+
JsonObject definitions,
157+
string? dbContextName,
158+
string? rootNamespace)
159+
{
160+
var namesDef = definitions["Names"]?.AsObject();
161+
if (namesDef is null) return;
162+
163+
var required = GetRequiredProperties(namesDef);
164+
var properties = namesDef["properties"]?.AsObject();
165+
if (properties is null) return;
166+
167+
var namesConfig = new JsonObject();
168+
169+
// Process only required properties
170+
foreach (var propName in required)
171+
{
172+
// Skip preview properties
173+
if (propName.Contains("-preview", StringComparison.OrdinalIgnoreCase))
174+
continue;
175+
176+
// Use custom values if provided
177+
if (propName == "dbcontext-name" && !string.IsNullOrEmpty(dbContextName))
178+
{
179+
namesConfig[propName] = dbContextName;
180+
}
181+
else if (propName == "root-namespace" && !string.IsNullOrEmpty(rootNamespace))
182+
{
183+
namesConfig[propName] = rootNamespace;
184+
}
185+
else
186+
{
187+
var propDef = properties[propName]?.AsObject();
188+
if (propDef is null) continue;
189+
190+
if (TryGetDefaultValue(propDef, propName, out var defaultValue))
191+
{
192+
namesConfig[propName] = defaultValue!;
193+
}
194+
else
195+
{
196+
// Provide sensible defaults for required string properties
197+
if (propName == "dbcontext-name")
198+
namesConfig[propName] = "ApplicationDbContext";
199+
else if (propName == "root-namespace")
200+
namesConfig[propName] = "EfcptProject";
201+
}
202+
}
203+
}
204+
205+
if (namesConfig.Count > 0)
206+
{
207+
config["names"] = namesConfig;
208+
}
209+
}
210+
211+
private static void ProcessFileLayout(JsonObject config, JsonObject definitions)
212+
{
213+
var fileLayoutDef = definitions["FileLayout"]?.AsObject();
214+
if (fileLayoutDef is null) return;
215+
216+
var required = GetRequiredProperties(fileLayoutDef);
217+
var properties = fileLayoutDef["properties"]?.AsObject();
218+
if (properties is null) return;
219+
220+
var fileLayoutConfig = new JsonObject();
221+
222+
// Process only required properties
223+
foreach (var propName in required)
224+
{
225+
// Skip preview properties
226+
if (propName.Contains("-preview", StringComparison.OrdinalIgnoreCase))
227+
continue;
228+
229+
var propDef = properties[propName]?.AsObject();
230+
if (propDef is null) continue;
231+
232+
if (TryGetDefaultValue(propDef, propName, out var defaultValue))
233+
{
234+
fileLayoutConfig[propName] = defaultValue;
235+
}
236+
}
237+
238+
if (fileLayoutConfig.Count > 0)
239+
{
240+
config["file-layout"] = fileLayoutConfig;
241+
}
242+
}
243+
244+
private static List<string> GetRequiredProperties(JsonObject definition)
245+
{
246+
var requiredArray = definition["required"]?.AsArray();
247+
if (requiredArray is null)
248+
return new List<string>();
249+
250+
return requiredArray
251+
.Select(item => item?.GetValue<string>())
252+
.Where(s => s is not null)
253+
.Cast<string>()
254+
.ToList();
255+
}
256+
257+
private static bool TryGetDefaultValue(JsonObject propertyDef, string propertyName, out JsonNode? defaultValue)
258+
{
259+
// Check if there's an explicit default value
260+
if (propertyDef.TryGetPropertyValue("default", out defaultValue) && defaultValue is not null)
261+
{
262+
defaultValue = defaultValue.DeepClone();
263+
return true;
264+
}
265+
266+
// Check type to determine implicit defaults
267+
var type = propertyDef["type"];
268+
if (type is null)
269+
{
270+
defaultValue = null;
271+
return false;
272+
}
273+
274+
// Handle type as string
275+
if (type is JsonValue typeValue)
276+
{
277+
var typeStr = typeValue.GetValue<string>();
278+
if (typeStr == "boolean")
279+
{
280+
defaultValue = JsonValue.Create(false);
281+
return true;
282+
}
283+
284+
defaultValue = null;
285+
return false;
286+
}
287+
288+
// Handle type as array (e.g., ["string", "null"]) - nullable types
289+
if (type is JsonArray typeArray)
290+
{
291+
// Return null for nullable properties
292+
defaultValue = JsonValue.Create<string?>(null);
293+
return true;
294+
}
295+
296+
defaultValue = null;
297+
return false;
298+
}
299+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Config Generator
2+
3+
This directory contains the `EfcptConfigGenerator` utility that generates default `efcpt-config.json` files from the EFCorePowerTools JSON schema.
4+
5+
## Purpose
6+
7+
The generator ensures that the default config files packaged with JD.Efcpt.Build match the structure and defaults produced by the efcpt CLI tool. This is important for:
8+
9+
1. **Consistency**: Users get the same config structure whether they use our templates or run efcpt directly
10+
2. **Maintainability**: When the schema changes, we can regenerate configs rather than manually updating them
11+
3. **Correctness**: Automatically excludes preview properties and uses schema-defined defaults
12+
13+
## Usage
14+
15+
### Generating Config Files
16+
17+
The generator can be used programmatically:
18+
19+
```csharp
20+
using JD.Efcpt.Build.Tasks.Config;
21+
22+
// From local schema file
23+
var config = EfcptConfigGenerator.GenerateFromFile(
24+
schemaPath: "path/to/efcpt-config.schema.json",
25+
dbContextName: "ApplicationDbContext",
26+
rootNamespace: "EfcptProject");
27+
28+
// From URL
29+
var config = await EfcptConfigGenerator.GenerateFromUrlAsync(
30+
schemaUrl: "https://raw.githubusercontent.com/.../efcpt-config.schema.json",
31+
dbContextName: "ApplicationDbContext",
32+
rootNamespace: "EfcptProject");
33+
34+
// Write to file
35+
File.WriteAllText("efcpt-config.json", config);
36+
```
37+
38+
### Updating Package Config Files
39+
40+
When the schema is updated, regenerate the packaged config files:
41+
42+
1. Update `/lib/efcpt-config.schema.json` if needed
43+
2. Run the generator to update both config files:
44+
- `/src/JD.Efcpt.Build/defaults/efcpt-config.json`
45+
- `/src/JD.Efcpt.Build.Templates/templates/efcptbuild/efcpt-config.json`
46+
47+
Example script:
48+
49+
```csharp
50+
var schemaPath = "lib/efcpt-config.schema.json";
51+
var config = EfcptConfigGenerator.GenerateFromFile(
52+
schemaPath,
53+
dbContextName: "ApplicationDbContext",
54+
rootNamespace: "EfcptProject");
55+
56+
File.WriteAllText("src/JD.Efcpt.Build/defaults/efcpt-config.json", config);
57+
File.WriteAllText("src/JD.Efcpt.Build.Templates/templates/efcptbuild/efcpt-config.json", config);
58+
```
59+
60+
## Generator Behavior
61+
62+
- **Includes all properties** with defined defaults (not just required ones)
63+
- **Excludes preview properties** (any property containing "-preview")
64+
- **Uses schema defaults** where specified
65+
- **Provides sensible defaults** for required properties without schema defaults:
66+
- `dbcontext-name`: "ApplicationDbContext"
67+
- `root-namespace`: "EfcptProject"
68+
- `output-path`: "Models"
69+
- **Sets nullable properties** to `null` by default
70+
71+
## When to Use This
72+
73+
This generator is **only needed at pack-time** for our own libraries. End users don't need it because:
74+
75+
1. The efcpt CLI automatically generates a default config if one is missing
76+
2. Our packages include pre-generated configs that match what efcpt produces
77+
3. Users can customize configs via MSBuild properties without regenerating files
78+
79+
## Testing
80+
81+
Tests are located in `/tests/JD.Efcpt.Build.Tests/Config/EfcptConfigGeneratorTests.cs` and verify:
82+
83+
- Valid JSON output
84+
- Correct structure (code-generation, names, file-layout, type-mappings sections)
85+
- Exclusion of preview properties
86+
- Custom name support
87+
- Schema default values

0 commit comments

Comments
 (0)