diff --git a/src/JD.Efcpt.Build.Tasks/Config/EfcptConfigGenerator.cs b/src/JD.Efcpt.Build.Tasks/Config/EfcptConfigGenerator.cs new file mode 100644 index 0000000..061b2ce --- /dev/null +++ b/src/JD.Efcpt.Build.Tasks/Config/EfcptConfigGenerator.cs @@ -0,0 +1,299 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Tasks; + +namespace JD.Efcpt.Build.Tasks.Config; + +/// +/// Generates efcpt-config.json from the EFCorePowerTools JSON schema. +/// +public static class EfcptConfigGenerator +{ + private const string PrimarySchemaUrl = "https://raw.githubusercontent.com/ErikEJ/EFCorePowerTools/master/samples/efcpt-config.schema.json"; + private const string FallbackSchemaUrl = "https://raw.githubusercontent.com/JerrettDavis/JD.Efcpt.Build/refs/heads/main/lib/efcpt-config.schema.json"; + + /// + /// Generates a default efcpt-config.json from a schema URL. + /// + /// URL to the schema (optional, tries primary then fallback) + /// Optional custom DbContext name (default: "ApplicationDbContext") + /// Optional custom root namespace (default: "EfcptProject") + /// Generated JSON string + public static async Task GenerateFromUrlAsync( + string? schemaUrl = null, + string? dbContextName = null, + string? rootNamespace = null) + { + schemaUrl ??= await TryGetSchemaUrlAsync(); + + using var client = new HttpClient(); + var schemaJson = await client.GetStringAsync(schemaUrl); + return GenerateFromSchema(schemaJson, dbContextName, rootNamespace, schemaUrl); + } + + /// + /// Tries to fetch schema from primary URL, falling back to secondary if needed. + /// + private static async Task TryGetSchemaUrlAsync() + { + using var client = new HttpClient(); + client.Timeout = TimeSpan.FromSeconds(5); + + try + { + await client.GetStringAsync(PrimarySchemaUrl); + return PrimarySchemaUrl; + } + catch + { + return FallbackSchemaUrl; + } + } + + /// + /// Generates a default efcpt-config.json from a local schema file. + /// + /// Path to the schema file + /// Optional custom DbContext name (default: "ApplicationDbContext") + /// Optional custom root namespace (default: "EfcptProject") + /// Optional schema URL to include in $schema property (default: primary schema URL) + /// Generated JSON string + public static string GenerateFromFile( + string schemaPath, + string? dbContextName = null, + string? rootNamespace = null, + string? schemaUrl = null) + { + var schemaJson = File.ReadAllText(schemaPath); + schemaUrl ??= PrimarySchemaUrl; + return GenerateFromSchema(schemaJson, dbContextName, rootNamespace, schemaUrl); + } + + /// + /// Generates a default efcpt-config.json from schema JSON string. + /// + /// The JSON schema as a string + /// Optional custom DbContext name (default: "ApplicationDbContext") + /// Optional custom root namespace (default: "EfcptProject") + /// Optional schema URL to include in $schema property (default: primary schema URL) + /// Generated JSON string + public static string GenerateFromSchema( + string schemaJson, + string? dbContextName = null, + string? rootNamespace = null, + string? schemaUrl = null) + { + var schema = JsonNode.Parse(schemaJson); + if (schema is null) + throw new InvalidOperationException("Failed to parse schema JSON"); + + var config = new JsonObject(); + + // Add $schema property first + schemaUrl ??= PrimarySchemaUrl; + config["$schema"] = schemaUrl; + + var definitions = schema["definitions"]?.AsObject(); + if (definitions is null) + throw new InvalidOperationException("Schema does not contain definitions section"); + + // Process each top-level section - only required properties + ProcessCodeGeneration(config, definitions); + ProcessFileLayout(config, definitions); + ProcessNames(config, definitions, dbContextName, rootNamespace); + // Don't process TypeMappings as it's not required + + // Serialize with indentation + var options = new JsonSerializerOptions + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + return JsonSerializer.Serialize(config, options); + } + + private static void ProcessCodeGeneration(JsonObject config, JsonObject definitions) + { + var codeGenDef = definitions["CodeGeneration"]?.AsObject(); + if (codeGenDef is null) return; + + var required = GetRequiredProperties(codeGenDef); + var properties = codeGenDef["properties"]?.AsObject(); + if (properties is null) return; + + var codeGenConfig = new JsonObject(); + + // Process only required properties + foreach (var propName in required) + { + // Skip preview properties + if (propName.Contains("-preview", StringComparison.OrdinalIgnoreCase)) + continue; + + var propDef = properties[propName]?.AsObject(); + if (propDef is null) continue; + + if (TryGetDefaultValue(propDef, propName, out var defaultValue)) + { + codeGenConfig[propName] = defaultValue; + } + } + + if (codeGenConfig.Count > 0) + { + config["code-generation"] = codeGenConfig; + } + } + + private static void ProcessNames( + JsonObject config, + JsonObject definitions, + string? dbContextName, + string? rootNamespace) + { + var namesDef = definitions["Names"]?.AsObject(); + if (namesDef is null) return; + + var required = GetRequiredProperties(namesDef); + var properties = namesDef["properties"]?.AsObject(); + if (properties is null) return; + + var namesConfig = new JsonObject(); + + // Process only required properties + foreach (var propName in required) + { + // Skip preview properties + if (propName.Contains("-preview", StringComparison.OrdinalIgnoreCase)) + continue; + + // Use custom values if provided + if (propName == "dbcontext-name" && !string.IsNullOrEmpty(dbContextName)) + { + namesConfig[propName] = dbContextName; + } + else if (propName == "root-namespace" && !string.IsNullOrEmpty(rootNamespace)) + { + namesConfig[propName] = rootNamespace; + } + else + { + var propDef = properties[propName]?.AsObject(); + if (propDef is null) continue; + + if (TryGetDefaultValue(propDef, propName, out var defaultValue)) + { + namesConfig[propName] = defaultValue!; + } + else + { + // Provide sensible defaults for required string properties + if (propName == "dbcontext-name") + namesConfig[propName] = "ApplicationDbContext"; + else if (propName == "root-namespace") + namesConfig[propName] = "EfcptProject"; + } + } + } + + if (namesConfig.Count > 0) + { + config["names"] = namesConfig; + } + } + + private static void ProcessFileLayout(JsonObject config, JsonObject definitions) + { + var fileLayoutDef = definitions["FileLayout"]?.AsObject(); + if (fileLayoutDef is null) return; + + var required = GetRequiredProperties(fileLayoutDef); + var properties = fileLayoutDef["properties"]?.AsObject(); + if (properties is null) return; + + var fileLayoutConfig = new JsonObject(); + + // Process only required properties + foreach (var propName in required) + { + // Skip preview properties + if (propName.Contains("-preview", StringComparison.OrdinalIgnoreCase)) + continue; + + var propDef = properties[propName]?.AsObject(); + if (propDef is null) continue; + + if (TryGetDefaultValue(propDef, propName, out var defaultValue)) + { + fileLayoutConfig[propName] = defaultValue; + } + } + + if (fileLayoutConfig.Count > 0) + { + config["file-layout"] = fileLayoutConfig; + } + } + + private static List GetRequiredProperties(JsonObject definition) + { + var requiredArray = definition["required"]?.AsArray(); + if (requiredArray is null) + return new List(); + + return requiredArray + .Select(item => item?.GetValue()) + .Where(s => s is not null) + .Cast() + .ToList(); + } + + private static bool TryGetDefaultValue(JsonObject propertyDef, string propertyName, out JsonNode? defaultValue) + { + // Check if there's an explicit default value + if (propertyDef.TryGetPropertyValue("default", out defaultValue) && defaultValue is not null) + { + defaultValue = defaultValue.DeepClone(); + return true; + } + + // Check type to determine implicit defaults + var type = propertyDef["type"]; + if (type is null) + { + defaultValue = null; + return false; + } + + // Handle type as string + if (type is JsonValue typeValue) + { + var typeStr = typeValue.GetValue(); + if (typeStr == "boolean") + { + defaultValue = JsonValue.Create(false); + return true; + } + + defaultValue = null; + return false; + } + + // Handle type as array (e.g., ["string", "null"]) - nullable types + if (type is JsonArray typeArray) + { + // Return null for nullable properties + defaultValue = JsonValue.Create(null); + return true; + } + + defaultValue = null; + return false; + } +} diff --git a/src/JD.Efcpt.Build.Tasks/Config/README.md b/src/JD.Efcpt.Build.Tasks/Config/README.md new file mode 100644 index 0000000..0b48e9e --- /dev/null +++ b/src/JD.Efcpt.Build.Tasks/Config/README.md @@ -0,0 +1,87 @@ +# Config Generator + +This directory contains the `EfcptConfigGenerator` utility that generates default `efcpt-config.json` files from the EFCorePowerTools JSON schema. + +## Purpose + +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: + +1. **Consistency**: Users get the same config structure whether they use our templates or run efcpt directly +2. **Maintainability**: When the schema changes, we can regenerate configs rather than manually updating them +3. **Correctness**: Automatically excludes preview properties and uses schema-defined defaults + +## Usage + +### Generating Config Files + +The generator can be used programmatically: + +```csharp +using JD.Efcpt.Build.Tasks.Config; + +// From local schema file +var config = EfcptConfigGenerator.GenerateFromFile( + schemaPath: "path/to/efcpt-config.schema.json", + dbContextName: "ApplicationDbContext", + rootNamespace: "EfcptProject"); + +// From URL +var config = await EfcptConfigGenerator.GenerateFromUrlAsync( + schemaUrl: "https://raw.githubusercontent.com/.../efcpt-config.schema.json", + dbContextName: "ApplicationDbContext", + rootNamespace: "EfcptProject"); + +// Write to file +File.WriteAllText("efcpt-config.json", config); +``` + +### Updating Package Config Files + +When the schema is updated, regenerate the packaged config files: + +1. Update `/lib/efcpt-config.schema.json` if needed +2. Run the generator to update both config files: + - `/src/JD.Efcpt.Build/defaults/efcpt-config.json` + - `/src/JD.Efcpt.Build.Templates/templates/efcptbuild/efcpt-config.json` + +Example script: + +```csharp +var schemaPath = "lib/efcpt-config.schema.json"; +var config = EfcptConfigGenerator.GenerateFromFile( + schemaPath, + dbContextName: "ApplicationDbContext", + rootNamespace: "EfcptProject"); + +File.WriteAllText("src/JD.Efcpt.Build/defaults/efcpt-config.json", config); +File.WriteAllText("src/JD.Efcpt.Build.Templates/templates/efcptbuild/efcpt-config.json", config); +``` + +## Generator Behavior + +- **Includes all properties** with defined defaults (not just required ones) +- **Excludes preview properties** (any property containing "-preview") +- **Uses schema defaults** where specified +- **Provides sensible defaults** for required properties without schema defaults: + - `dbcontext-name`: "ApplicationDbContext" + - `root-namespace`: "EfcptProject" + - `output-path`: "Models" +- **Sets nullable properties** to `null` by default + +## When to Use This + +This generator is **only needed at pack-time** for our own libraries. End users don't need it because: + +1. The efcpt CLI automatically generates a default config if one is missing +2. Our packages include pre-generated configs that match what efcpt produces +3. Users can customize configs via MSBuild properties without regenerating files + +## Testing + +Tests are located in `/tests/JD.Efcpt.Build.Tests/Config/EfcptConfigGeneratorTests.cs` and verify: + +- Valid JSON output +- Correct structure (code-generation, names, file-layout, type-mappings sections) +- Exclusion of preview properties +- Custom name support +- Schema default values diff --git a/src/JD.Efcpt.Build.Templates/templates/efcptbuild/efcpt-config.json b/src/JD.Efcpt.Build.Templates/templates/efcptbuild/efcpt-config.json index 63c38da..bf11da2 100644 --- a/src/JD.Efcpt.Build.Templates/templates/efcptbuild/efcpt-config.json +++ b/src/JD.Efcpt.Build.Templates/templates/efcptbuild/efcpt-config.json @@ -1,9 +1,26 @@ { "$schema": "https://raw.githubusercontent.com/ErikEJ/EFCorePowerTools/master/samples/efcpt-config.schema.json", + "code-generation": { + "enable-on-configuring": false, + "type": "all", + "use-database-names": false, + "use-data-annotations": false, + "use-nullable-reference-types": false, + "use-inflector": true, + "use-legacy-inflector": false, + "use-many-to-many-entity": false, + "use-t4": false, + "remove-defaultsql-from-bool-properties": false, + "soft-delete-obsolete-files": true, + "use-alternate-stored-procedure-resultset-discovery": false + }, + "file-layout": { + "output-path": "Models" + }, "names": { "root-namespace": "EfcptProject", "dbcontext-name": "ApplicationDbContext", "dbcontext-namespace": "EfcptProject.Data", "model-namespace": "EfcptProject.Data.Entities" } -} +} \ No newline at end of file diff --git a/src/JD.Efcpt.Build/defaults/efcpt-config.json b/src/JD.Efcpt.Build/defaults/efcpt-config.json index fe32bbc..d19d395 100644 --- a/src/JD.Efcpt.Build/defaults/efcpt-config.json +++ b/src/JD.Efcpt.Build/defaults/efcpt-config.json @@ -1,50 +1,24 @@ { "$schema": "https://raw.githubusercontent.com/ErikEJ/EFCorePowerTools/master/samples/efcpt-config.schema.json", - "code-generation": - { - "enable-on-configuring": false, - "type": "all", - "use-database-names": false, - "use-data-annotations": false, - "use-nullable-reference-types": true, - "use-inflector": true, - "use-legacy-inflector": false, - "use-many-to-many-entity": false, - "use-t4": true, - "remove-defaultsql-from-bool-properties": false, - "soft-delete-obsolete-files": false, - "discover-multiple-stored-procedure-resultsets-preview": false, - "use-alternate-stored-procedure-resultset-discovery": false, - "t4-template-path": null + "code-generation": { + "enable-on-configuring": false, + "type": "all", + "use-database-names": false, + "use-data-annotations": false, + "use-nullable-reference-types": false, + "use-inflector": true, + "use-legacy-inflector": false, + "use-many-to-many-entity": false, + "use-t4": false, + "remove-defaultsql-from-bool-properties": false, + "soft-delete-obsolete-files": true, + "use-alternate-stored-procedure-resultset-discovery": false }, - "names": - { - "root-namespace": "MyProject", - "dbcontext-name": "MyDbContext", - "dbcontext-namespace": null, - "model-namespace": null + "file-layout": { + "output-path": "Models" }, - "file-layout": - { - "output-path": "Models", - "output-dbcontext-path": null, - "split-dbcontext-preview": false, - "use-schema-folders-preview": true, - "use-schema-namespaces-preview": false - }, - "type-mappings": - { - "use-DateOnly-TimeOnly": true, - "use-HierarchyId": true, - "use-spatial": true, - "use-NodaTime": false - }, - "replacements": - { - "preserve-casing-with-regex": false, - "uncountable-words": [ - "Status", - "Data" - ] - } -} + "names": { + "dbcontext-name": "ApplicationDbContext", + "root-namespace": "EfcptProject" + } +} \ No newline at end of file diff --git a/tests/JD.Efcpt.Build.Tests/Config/EfcptConfigGeneratorTests.cs b/tests/JD.Efcpt.Build.Tests/Config/EfcptConfigGeneratorTests.cs new file mode 100644 index 0000000..5fca4cd --- /dev/null +++ b/tests/JD.Efcpt.Build.Tests/Config/EfcptConfigGeneratorTests.cs @@ -0,0 +1,228 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Text.Json.Nodes; +using JD.Efcpt.Build.Tasks.Config; +using Xunit; + +namespace JD.Efcpt.Build.Tests.Config; + +public class EfcptConfigGeneratorTests +{ + private readonly string _schemaPath; + + public EfcptConfigGeneratorTests() + { + // Locate the schema file relative to the test project + var repoRoot = FindRepoRoot(); + _schemaPath = Path.Combine(repoRoot, "lib", "efcpt-config.schema.json"); + + if (!File.Exists(_schemaPath)) + throw new FileNotFoundException($"Schema file not found at: {_schemaPath}"); + } + + [Fact] + public void GenerateFromFile_ProducesValidJson() + { + // Act + var result = EfcptConfigGenerator.GenerateFromFile(_schemaPath); + + // Assert + Assert.NotNull(result); + Assert.NotEmpty(result); + + // Verify it's valid JSON + var parsed = JsonNode.Parse(result); + Assert.NotNull(parsed); + + // Verify $schema property is present + Assert.NotNull(parsed["$schema"]); + Assert.Equal("https://raw.githubusercontent.com/ErikEJ/EFCorePowerTools/master/samples/efcpt-config.schema.json", + parsed["$schema"]?.GetValue()); + } + + [Fact] + public void GenerateFromFile_IncludesCodeGenerationSection() + { + // Act + var result = EfcptConfigGenerator.GenerateFromFile(_schemaPath); + var config = JsonNode.Parse(result); + + // Assert + Assert.NotNull(config); + var codeGen = config["code-generation"]; + Assert.NotNull(codeGen); + + // Verify required properties exist + Assert.NotNull(codeGen["enable-on-configuring"]); + Assert.NotNull(codeGen["type"]); + Assert.NotNull(codeGen["use-database-names"]); + Assert.NotNull(codeGen["use-data-annotations"]); + Assert.NotNull(codeGen["use-nullable-reference-types"]); + Assert.NotNull(codeGen["use-inflector"]); + Assert.NotNull(codeGen["use-legacy-inflector"]); + Assert.NotNull(codeGen["use-many-to-many-entity"]); + Assert.NotNull(codeGen["use-t4"]); + Assert.NotNull(codeGen["remove-defaultsql-from-bool-properties"]); + Assert.NotNull(codeGen["soft-delete-obsolete-files"]); + Assert.NotNull(codeGen["use-alternate-stored-procedure-resultset-discovery"]); + } + + [Fact] + public void GenerateFromFile_IncludesNamesSection() + { + // Act + var result = EfcptConfigGenerator.GenerateFromFile(_schemaPath); + var config = JsonNode.Parse(result); + + // Assert + Assert.NotNull(config); + var names = config["names"]; + Assert.NotNull(names); + + // Verify required properties exist with defaults + Assert.Equal("ApplicationDbContext", names["dbcontext-name"]?.GetValue()); + Assert.Equal("EfcptProject", names["root-namespace"]?.GetValue()); + } + + [Fact] + public void GenerateFromFile_IncludesFileLayoutSection() + { + // Act + var result = EfcptConfigGenerator.GenerateFromFile(_schemaPath); + var config = JsonNode.Parse(result); + + // Assert + Assert.NotNull(config); + var fileLayout = config["file-layout"]; + Assert.NotNull(fileLayout); + + // Verify required properties exist + Assert.NotNull(fileLayout["output-path"]); + Assert.Equal("Models", fileLayout["output-path"]?.GetValue()); + } + + [Fact] + public void GenerateFromFile_ExcludesPreviewProperties() + { + // Act + var result = EfcptConfigGenerator.GenerateFromFile(_schemaPath); + var config = JsonNode.Parse(result); + + // Assert - verify no preview properties are present + Assert.NotNull(config); + var jsonString = result.ToLowerInvariant(); + Assert.DoesNotContain("-preview", jsonString); + } + + [Fact] + public void GenerateFromFile_WithCustomNames() + { + // Act + var result = EfcptConfigGenerator.GenerateFromFile( + _schemaPath, + dbContextName: "MyCustomContext", + rootNamespace: "MyCustomNamespace"); + + var config = JsonNode.Parse(result); + + // Assert + Assert.NotNull(config); + var names = config["names"]; + Assert.NotNull(names); + Assert.Equal("MyCustomContext", names["dbcontext-name"]?.GetValue()); + Assert.Equal("MyCustomNamespace", names["root-namespace"]?.GetValue()); + } + + [Fact] + public void GenerateFromFile_UsesSchemaDefaults() + { + // Act + var result = EfcptConfigGenerator.GenerateFromFile(_schemaPath); + var config = JsonNode.Parse(result); + + // Assert - verify defaults from schema + Assert.NotNull(config); + var codeGen = config["code-generation"]; + Assert.NotNull(codeGen); + + // Check known defaults from schema + Assert.Equal("all", codeGen["type"]?.GetValue()); + Assert.True(codeGen["use-inflector"]?.GetValue()); + Assert.True(codeGen["soft-delete-obsolete-files"]?.GetValue()); + } + + [Fact] + public void GenerateFromFile_ProducesExpectedStructure() + { + // Act + var result = EfcptConfigGenerator.GenerateFromFile(_schemaPath); + + // Assert - verify the structure matches expected format + Assert.Contains("\"code-generation\":", result); + Assert.Contains("\"names\":", result); + Assert.Contains("\"file-layout\":", result); + Assert.Contains("\"$schema\":", result); + + // Verify indentation (should be formatted) + Assert.Contains(" ", result); + + // Verify type-mappings is NOT present (not required) + Assert.DoesNotContain("\"type-mappings\":", result); + } + + [Fact] + public void GenerateFromFile_OnlyIncludesRequiredProperties() + { + // Act + var result = EfcptConfigGenerator.GenerateFromFile(_schemaPath); + var config = JsonNode.Parse(result); + + // Assert + Assert.NotNull(config); + + // Verify only required sections are present + Assert.NotNull(config["$schema"]); + Assert.NotNull(config["code-generation"]); + Assert.NotNull(config["names"]); + Assert.NotNull(config["file-layout"]); + + // Verify optional sections are NOT present + Assert.Null(config["type-mappings"]); + Assert.Null(config["tables"]); + Assert.Null(config["views"]); + Assert.Null(config["stored-procedures"]); + Assert.Null(config["functions"]); + Assert.Null(config["replacements"]); + + // Verify code-generation has exactly 12 required properties + var codeGen = config["code-generation"]?.AsObject(); + Assert.NotNull(codeGen); + Assert.Equal(12, codeGen.Count); + + // Verify names has exactly 2 required properties + var names = config["names"]?.AsObject(); + Assert.NotNull(names); + Assert.Equal(2, names.Count); + + // Verify file-layout has exactly 1 required property + var fileLayout = config["file-layout"]?.AsObject(); + Assert.NotNull(fileLayout); + Assert.Single(fileLayout); + } + + private static string FindRepoRoot() + { + var current = Directory.GetCurrentDirectory(); + while (current != null) + { + if (Directory.Exists(Path.Combine(current, ".git"))) + return current; + + var parent = Directory.GetParent(current); + current = parent?.FullName; + } + + throw new DirectoryNotFoundException("Could not find repository root"); + } +}