Skip to content

Commit c073a69

Browse files
authored
Add dotnet-ef JSON config defaults and validation features (#37966)
Add .config/dotnet-ef.json discovery from current directory up to repo root and apply config values for project, startup project, framework, configuration, and context when CLI options are not explicitly provided. Introduce resource-backed validation and error handling for invalid JSON, unsupported/unknown properties, invalid value types, and unreadable files. Add dotnet-ef tests for config discovery/precedence, path resolution, argument propagation, and failure scenarios. Impact: users can set consistent dotnet-ef defaults in source-controlled config with existing CLI precedence preserved, reducing repetitive command arguments without breaking current behavior. Fixes #35231
1 parent b4aa5c5 commit c073a69

5 files changed

Lines changed: 583 additions & 15 deletions

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Text.Json;
5+
using Microsoft.EntityFrameworkCore.Tools.Properties;
6+
7+
namespace Microsoft.EntityFrameworkCore.Tools;
8+
9+
internal sealed record DotNetEfConfig(
10+
string Path,
11+
string? Project,
12+
string? StartupProject,
13+
string? Context,
14+
string? Framework,
15+
string? Configuration,
16+
string? Runtime,
17+
bool? Verbose,
18+
bool? NoColor,
19+
bool? PrefixOutput);
20+
21+
internal static class DotNetEfConfigLoader
22+
{
23+
private const string ConfigDirectoryName = ".config";
24+
private const string ConfigFileName = "dotnet-ef.json";
25+
26+
public static DotNetEfConfig? Load(string currentDirectory)
27+
{
28+
var configPath = Discover(currentDirectory);
29+
return configPath == null ? null : LoadFile(configPath);
30+
}
31+
32+
private static string? Discover(string currentDirectory)
33+
{
34+
var directory = new DirectoryInfo(Path.GetFullPath(currentDirectory));
35+
36+
while (directory != null)
37+
{
38+
var configPath = Path.Combine(directory.FullName, ConfigDirectoryName, ConfigFileName);
39+
if (File.Exists(configPath))
40+
{
41+
return configPath;
42+
}
43+
44+
directory = directory.Parent;
45+
}
46+
47+
return null;
48+
}
49+
50+
internal static DotNetEfConfig LoadFile(string configPath)
51+
{
52+
var fullPath = Path.GetFullPath(configPath);
53+
54+
JsonDocument document;
55+
try
56+
{
57+
using var stream = File.OpenRead(fullPath);
58+
document = JsonDocument.Parse(stream);
59+
}
60+
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
61+
{
62+
throw new CommandException(Resources.DotNetEfConfigReadFailed(fullPath, exception.Message), exception);
63+
}
64+
catch (JsonException exception)
65+
{
66+
throw new CommandException(Resources.DotNetEfConfigInvalidJson(fullPath, exception.Message), exception);
67+
}
68+
69+
using (document)
70+
{
71+
if (document.RootElement.ValueKind != JsonValueKind.Object)
72+
{
73+
throw new CommandException(Resources.DotNetEfConfigInvalidRoot(fullPath));
74+
}
75+
76+
var configDirectory = Directory.GetParent(Path.GetDirectoryName(fullPath)!)!.FullName;
77+
string? project = null;
78+
string? startupProject = null;
79+
string? context = null;
80+
string? framework = null;
81+
string? configuration = null;
82+
string? runtime = null;
83+
bool? verbose = null;
84+
bool? noColor = null;
85+
bool? prefixOutput = null;
86+
87+
foreach (var property in document.RootElement.EnumerateObject())
88+
{
89+
switch (property.Name)
90+
{
91+
case "project":
92+
project = ResolvePath(configDirectory, ValidateValue(fullPath, property));
93+
break;
94+
case "startupProject":
95+
startupProject = ResolvePath(configDirectory, ValidateValue(fullPath, property));
96+
break;
97+
case "context":
98+
context = ValidateValue(fullPath, property);
99+
break;
100+
case "framework":
101+
framework = ValidateValue(fullPath, property);
102+
break;
103+
case "configuration":
104+
configuration = ValidateValue(fullPath, property);
105+
break;
106+
case "runtime":
107+
runtime = ValidateValue(fullPath, property);
108+
break;
109+
case "verbose":
110+
verbose = ValidateBoolValue(fullPath, property);
111+
break;
112+
case "noColor":
113+
noColor = ValidateBoolValue(fullPath, property);
114+
break;
115+
case "prefixOutput":
116+
prefixOutput = ValidateBoolValue(fullPath, property);
117+
break;
118+
default:
119+
throw new CommandException(Resources.DotNetEfConfigUnknownProperty(fullPath, property.Name));
120+
}
121+
}
122+
123+
return new DotNetEfConfig(fullPath, project, startupProject, context, framework, configuration, runtime, verbose, noColor, prefixOutput);
124+
}
125+
}
126+
127+
private static string ValidateValue(string fullPath, JsonProperty property)
128+
=> property.Value.ValueKind == JsonValueKind.String
129+
&& !string.IsNullOrWhiteSpace(property.Value.GetString())
130+
? property.Value.GetString()!
131+
: throw new CommandException(Resources.DotNetEfConfigInvalidValue(fullPath, property.Name));
132+
133+
private static bool ValidateBoolValue(string fullPath, JsonProperty property)
134+
=> property.Value.ValueKind == JsonValueKind.True || property.Value.ValueKind == JsonValueKind.False
135+
? property.Value.GetBoolean()
136+
: throw new CommandException(Resources.DotNetEfConfigInvalidBoolValue(fullPath, property.Name));
137+
138+
139+
140+
private static string ResolvePath(string configDirectory, string path)
141+
=> Path.IsPathRooted(path)
142+
? path
143+
: Path.GetFullPath(Path.Combine(configDirectory, path));
144+
}

src/dotnet-ef/Properties/Resources.Designer.cs

Lines changed: 48 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/dotnet-ef/Properties/Resources.resx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,24 @@
192192
<data name="DotnetEfFullName" xml:space="preserve">
193193
<value>Entity Framework Core .NET Command-line Tools</value>
194194
</data>
195+
<data name="DotNetEfConfigInvalidJson" xml:space="preserve">
196+
<value>Unable to read '{configFile}'. Fix the JSON and try again. {details}</value>
197+
</data>
198+
<data name="DotNetEfConfigInvalidRoot" xml:space="preserve">
199+
<value>Unable to read '{configFile}'. The file must contain a JSON object.</value>
200+
</data>
201+
<data name="DotNetEfConfigInvalidValue" xml:space="preserve">
202+
<value>Unable to read '{configFile}'. The '{propertyName}' property must be a non-empty JSON string.</value>
203+
</data>
204+
<data name="DotNetEfConfigInvalidBoolValue" xml:space="preserve">
205+
<value>Unable to read '{configFile}'. The '{propertyName}' property must be a boolean.</value>
206+
</data>
207+
<data name="DotNetEfConfigReadFailed" xml:space="preserve">
208+
<value>Unable to read '{configFile}'. Ensure the file is accessible and try again. {details}</value>
209+
</data>
210+
<data name="DotNetEfConfigUnknownProperty" xml:space="preserve">
211+
<value>Unable to read '{configFile}'. Remove the unsupported '{propertyName}' property.</value>
212+
</data>
195213
<data name="EFFullName" xml:space="preserve">
196214
<value>Entity Framework Core Command-line Tools</value>
197215
</data>

src/dotnet-ef/RootCommand.cs

Lines changed: 85 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -59,31 +59,46 @@ protected override int Execute(string[] _)
5959
return ShowHelp(_help.HasValue(), commands);
6060
}
6161

62+
var config = DotNetEfConfigLoader.Load(Directory.GetCurrentDirectory());
63+
var projectPath = _project!.Value() ?? config?.Project;
64+
var startupProjectPath = _startupProject!.Value() ?? config?.StartupProject;
65+
var framework = _framework!.Value() ?? config?.Framework;
66+
var configuration = _configuration!.Value() ?? config?.Configuration;
67+
var runtime = _runtime!.Value() ?? config?.Runtime;
68+
var context = ResolveContext(_args!, config?.Context);
69+
var remainingArguments = CreateRemainingArguments(_args!, context);
70+
71+
if (config?.Verbose == true && !ContainsOption(_args!, "-v", "--verbose"))
72+
Reporter.IsVerbose = true;
73+
74+
if (config?.NoColor == true && !ContainsOption(_args!, "--no-color"))
75+
Reporter.NoColor = true;
76+
77+
if (config?.PrefixOutput == true && !ContainsOption(_args!, "--prefix-output"))
78+
Reporter.PrefixOutput = true;
79+
6280
var (projectFile, startupProjectFile) = ResolveProjects(
63-
_project!.Value(),
64-
_startupProject!.Value());
81+
projectPath,
82+
startupProjectPath);
6583

6684
Reporter.WriteVerbose(Resources.UsingProject(projectFile));
6785
Reporter.WriteVerbose(Resources.UsingStartupProject(startupProjectFile));
6886

6987
var project = Project.FromFile(
7088
projectFile,
71-
_framework!.Value(),
72-
_configuration!.Value(),
73-
_runtime!.Value());
89+
framework,
90+
configuration,
91+
runtime);
7492
var startupProject = Project.FromFile(
7593
startupProjectFile,
76-
_framework!.Value(),
77-
_configuration!.Value(),
78-
_runtime!.Value());
94+
framework,
95+
configuration,
96+
runtime);
7997

8098
if (!_noBuild!.HasValue())
8199
{
82100
Reporter.WriteInformation(Resources.BuildStarted);
83-
var skipOptimization = _args!.Count > 2
84-
&& _args[0] == "dbcontext"
85-
&& _args[1] == "optimize"
86-
&& !_args.Any(a => a == "--no-scaffold");
101+
var skipOptimization = ShouldSkipOptimization(_args!);
87102
startupProject.Build(skipOptimization ? ["/p:EFScaffoldModelStage=none", "/p:EFPrecompileQueriesStage=none"] : null);
88103
Reporter.WriteInformation(Resources.BuildSucceeded);
89104
}
@@ -169,7 +184,7 @@ protected override int Execute(string[] _)
169184
Resources.UnsupportedFramework(startupProject.ProjectName, targetFramework.Identifier));
170185
}
171186

172-
args.AddRange(_args!);
187+
args.AddRange(remainingArguments);
173188
args.Add("--assembly");
174189
args.Add(targetPath);
175190
args.Add("--project");
@@ -194,10 +209,10 @@ protected override int Execute(string[] _)
194209
args.Add(designAssembly);
195210
}
196211

197-
if (_configuration.HasValue())
212+
if (configuration != null)
198213
{
199214
args.Add("--configuration");
200-
args.Add(_configuration.Value()!);
215+
args.Add(configuration);
201216
}
202217

203218
if (string.Equals(project.Nullable, "enable", StringComparison.OrdinalIgnoreCase)
@@ -312,6 +327,61 @@ private static List<string> ResolveProjects(string? path)
312327
return projectFiles;
313328
}
314329

330+
internal static string? ResolveContext(IList<string> args, string? configValue)
331+
=> configValue != null
332+
&& AppliesToContext(args)
333+
&& !ContainsOption(args, "-c", "--context")
334+
? configValue
335+
: null;
336+
337+
internal static List<string> CreateRemainingArguments(
338+
IList<string> args,
339+
string? context)
340+
{
341+
var remainingArguments = new List<string>(args);
342+
343+
if (context != null)
344+
{
345+
remainingArguments.Add("--context");
346+
remainingArguments.Add(context);
347+
}
348+
349+
return remainingArguments;
350+
}
351+
352+
private static bool AppliesToContext(IList<string> args)
353+
=> args.Count >= 2
354+
&& (args[0], args[1]) switch
355+
{
356+
("database", "drop") => true,
357+
("database", "update") => true,
358+
("dbcontext", "info") => true,
359+
("dbcontext", "optimize") => true,
360+
("dbcontext", "script") => true,
361+
("migrations", "add") => true,
362+
("migrations", "bundle") => true,
363+
("migrations", "has-pending-model-changes") => true,
364+
("migrations", "list") => true,
365+
("migrations", "remove") => true,
366+
("migrations", "script") => true,
367+
_ => false
368+
};
369+
370+
private static bool ContainsOption(
371+
IList<string> args,
372+
params string[] names)
373+
=> args.Any(
374+
argument => names.Any(
375+
name => string.Equals(argument, name, StringComparison.Ordinal)
376+
|| argument.StartsWith(name + "=", StringComparison.Ordinal)
377+
|| argument.StartsWith(name + ":", StringComparison.Ordinal)));
378+
379+
internal static bool ShouldSkipOptimization(IList<string> args)
380+
=> args.Count > 2
381+
&& args[0] == "dbcontext"
382+
&& args[1] == "optimize"
383+
&& !args.Any(a => a == "--no-scaffold");
384+
315385
private static string GetVersion()
316386
=> typeof(RootCommand).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()!
317387
.InformationalVersion;

0 commit comments

Comments
 (0)