Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- Preserve compatible computed-column arithmetic grouping parentheses during table compatibility reconciliation.

### Added
- `sqlct init` now scans the target project directory for existing `Data/*.sql` files, extracts table names from the file names, and proposes a `trackedTables` list: in interactive mode the user is prompted to confirm before the tables are written to config; in non-interactive mode (with `--project-dir`) the tables are added automatically.
- Discover and script SQL CLR scalar functions as `Function` objects.
- Discover and script SQL CLR table-valued functions as `Function` objects.
- Discover and script SQL CLR stored procedures as `StoredProcedure` objects.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ When `data.trackedTables` is configured, `status`, `diff`, and `pull` also proce
## Selective data scripting
Tracked-table data scripting is configuration-driven.

- `sqlct init` scans the project directory for existing `Data/*.sql` files and proposes their table names as initial `trackedTables`; in interactive mode you are prompted to confirm, in non-interactive mode (`--project-dir`) they are added automatically.
- `sqlct data track` matches user tables in the current database against a positional pattern (`schema.*`, `*.name`, `schema.name`), `--object <pattern>`, or `--filter <regex>`, and asks for confirmation before updating `data.trackedTables`. Exactly one selector must be provided.
- `sqlct data untrack` previews tracked matches (same selector forms) and asks for confirmation before removing them.
- `sqlct data list` shows the currently tracked tables from config.
Expand Down
5 changes: 5 additions & 0 deletions specs/01-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ Behavior:
- Password input during interactive prompts is masked.
- When connection details are collected, attempt a connection test **before** creating any project files (5-second timeout).
- If the connection test fails, print troubleshooting hints and prompt `"Proceed anyway? [y/N]:"`. If declined, exit without creating any files. If confirmed, proceed to create the directory structure and write config.
- After seeding the project structure, scan `Data/*.sql` files in the project directory. Files whose names match the `Schema.Table_Data.sql` convention are parsed as `schema.table` candidates for `data.trackedTables`.
- In interactive mode: display the proposed table list and prompt `"Add these tables to trackedTables in config? [Y/n]:"` (default: yes). If confirmed, write the tables to `data.trackedTables` in the config. If declined, leave `data.trackedTables` empty.
- In non-interactive mode (with `--project-dir`): auto-include all discovered tables in `data.trackedTables` without a prompt.
- Files whose names do not match the naming convention are silently ignored.
- If no `Data/*.sql` files are found, `data.trackedTables` remains empty and no prompt is shown.
- After init completes, print context-aware next-steps: `pull`, `status`, `diff` on success; edit config and run `sqlct config` on failure.
- Exit codes:
- `0` success.
Expand Down
65 changes: 64 additions & 1 deletion src/SqlChangeTracker/Commands/InitCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using SqlChangeTracker.Config;
using SqlChangeTracker.Output;
using SqlChangeTracker.Sql;
using SqlChangeTracker.Sync;

namespace SqlChangeTracker.Commands;

Expand Down Expand Up @@ -85,6 +86,29 @@ public override int Execute(CommandContext context, InitCommandSettings settings
}

var config = BuildConfig(connectionSetup);

// Scan for existing Data/*.sql files and propose trackedTables.
var proposedTables = ScanDataFileTableNames(projectDir);
IReadOnlyList<string>? addedTrackedTables = null;
if (proposedTables.Count > 0)
{
bool addTrackedTables;
if (isInteractive)
{
addTrackedTables = ConfirmAddTrackedTables(proposedTables);
}
else
{
addTrackedTables = true;
}

if (addTrackedTables)
{
config.Data.TrackedTables.AddRange(proposedTables);
addedTrackedTables = proposedTables;
}
}

var configWriter = new SqlctConfigWriter();
var configPath = SqlctConfigWriter.GetDefaultPath(projectDir);
var configResult = configWriter.Write(configPath, config);
Expand All @@ -98,7 +122,7 @@ public override int Execute(CommandContext context, InitCommandSettings settings

var created = projectSeedResult.Created.Concat(configResult.Created).ToList();
var skipped = projectSeedResult.Skipped.Concat(configResult.Skipped).ToList();
var result = new InitResult("init", displayProjectDir, created, skipped, connectionTestResult, nextSteps);
var result = new InitResult("init", displayProjectDir, created, skipped, connectionTestResult, nextSteps, addedTrackedTables);
output.WriteInit(result);
return ExitCodes.Success;
}
Expand Down Expand Up @@ -286,6 +310,45 @@ private static string ReadPassword()
return password.ToString();
}

private static IReadOnlyList<string> ScanDataFileTableNames(string projectDir)
{
var dataDir = Path.Combine(projectDir, "Data");
if (!Directory.Exists(dataDir))
{
return [];
}

var tables = new List<string>();
foreach (var file in Directory.GetFiles(dataDir, "*.sql", SearchOption.TopDirectoryOnly))
{
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file);
if (SyncCommandService.TryParseDataFileName(fileNameWithoutExtension, out var schema, out var name))
{
tables.Add($"{schema}.{name}");
}
}

return tables
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(t => t, StringComparer.OrdinalIgnoreCase)
.ToList();
}

private static bool ConfirmAddTrackedTables(IReadOnlyList<string> proposedTables)
{
Console.WriteLine();
Console.WriteLine($"Found {proposedTables.Count} existing Data/*.sql file(s). Proposed trackedTables:");
foreach (var table in proposedTables)
{
Console.WriteLine($" - {table}");
}
Console.Write("Add these tables to trackedTables in config? [Y/n]: ");
var response = Console.ReadLine()?.Trim() ?? string.Empty;
return string.IsNullOrWhiteSpace(response)
|| string.Equals(response, "y", StringComparison.OrdinalIgnoreCase)
|| string.Equals(response, "yes", StringComparison.OrdinalIgnoreCase);
}

private static bool ConfirmCurrentDirectory(string displayProjectDir)
{
Console.Write($"Initialize project in current directory '{displayProjectDir}'? [y/N]: ");
Expand Down
3 changes: 2 additions & 1 deletion src/SqlChangeTracker/Config/Models.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ internal sealed record InitResult(
IReadOnlyList<string> Created,
IReadOnlyList<string> Skipped,
InitConnectionTestResult? ConnectionTest = null,
IReadOnlyList<string>? NextSteps = null);
IReadOnlyList<string>? NextSteps = null,
IReadOnlyList<string>? TrackedTables = null);

internal sealed record InitConnectionTestResult(
bool Success,
Expand Down
12 changes: 11 additions & 1 deletion src/SqlChangeTracker/Output/OutputFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ public void WriteInit(InitResult result)
connectionTest = result.ConnectionTest != null
? new { result.ConnectionTest.Success, result.ConnectionTest.ErrorMessage }
: (object?)null,
nextSteps = result.NextSteps
nextSteps = result.NextSteps,
trackedTables = result.TrackedTables
};
WriteJson(payload);
return;
Expand Down Expand Up @@ -128,6 +129,15 @@ public void WriteInit(InitResult result)
}
}

if (result.TrackedTables?.Count > 0)
{
Console.WriteLine("Tracked tables:");
foreach (var table in result.TrackedTables)
{
Console.WriteLine($" {table}");
}
}

if (result.NextSteps != null && result.NextSteps.Count > 0)
{
Console.WriteLine("Next steps:");
Expand Down
1 change: 1 addition & 0 deletions src/SqlChangeTracker/PACKAGE_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ Feature-backed object types are included when the target database exposes them,
## Selective Data Scripting
Tracked-table data scripting is configuration-driven.

- `sqlct init` scans the project directory for existing `Data/*.sql` files and proposes their table names as initial `trackedTables`; in interactive mode you are prompted to confirm, in non-interactive mode (`--project-dir`) they are added automatically.
- `sqlct data track` matches user tables in the current database against a positional pattern (`schema.*`, `*.name`, `schema.name`), `--object <pattern>`, or `--filter <regex>`, and asks for confirmation before updating `data.trackedTables`. Exactly one selector must be provided.
- `sqlct data untrack` previews tracked matches (same selector forms) and asks for confirmation before removing them.
- `sqlct data list` shows the currently tracked tables from config.
Expand Down
188 changes: 188 additions & 0 deletions tests/SqlChangeTracker.Tests/Commands/InitAndConfigCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,194 @@ public void Config_WithConfigSwitch_ReturnsParsingFailure()
}
}

[Fact]
public void Init_WithProjectDir_WithExistingDataFiles_AutoIncludesTrackedTables()
{
var tempDir = CreateTempDir();

try
{
var projectDir = Path.Combine(tempDir, "project");
var dataDir = Path.Combine(projectDir, "Data");
Directory.CreateDirectory(dataDir);
File.WriteAllText(Path.Combine(dataDir, "dbo.Customer_Data.sql"), "-- data");
File.WriteAllText(Path.Combine(dataDir, "Sales.Order_Data.sql"), "-- data");

var exitCode = Program.Main(["init", "--project-dir", projectDir]);

Assert.Equal(ExitCodes.Success, exitCode);

var configPath = Path.Combine(projectDir, ConfigFileNames.SqlctConfigFileName);
Assert.True(File.Exists(configPath));

var config = new SqlctConfigReader().Read(configPath);
Assert.True(config.Success);
Assert.Contains("dbo.Customer", config.Config!.Data.TrackedTables, StringComparer.OrdinalIgnoreCase);
Assert.Contains("Sales.Order", config.Config!.Data.TrackedTables, StringComparer.OrdinalIgnoreCase);
}
finally
{
CleanupTempDir(tempDir);
}
}

[Fact]
public void Init_WithProjectDir_WithNoDataFiles_ProducesEmptyTrackedTables()
{
var tempDir = CreateTempDir();

try
{
var projectDir = Path.Combine(tempDir, "project");

var exitCode = Program.Main(["init", "--project-dir", projectDir]);

Assert.Equal(ExitCodes.Success, exitCode);

var configPath = Path.Combine(projectDir, ConfigFileNames.SqlctConfigFileName);
Assert.True(File.Exists(configPath));

var config = new SqlctConfigReader().Read(configPath);
Assert.True(config.Success);
Assert.Empty(config.Config!.Data.TrackedTables);
}
finally
{
CleanupTempDir(tempDir);
}
}

[Fact]
public void Init_WithoutProjectDir_WithExistingDataFiles_WhenConfirmed_IncludesTrackedTables()
{
var tempDir = CreateTempDir();
var originalCurrentDirectory = Environment.CurrentDirectory;
var originalInput = Console.In;

try
{
Environment.CurrentDirectory = tempDir;
var dataDir = Path.Combine(tempDir, "Data");
Directory.CreateDirectory(dataDir);
File.WriteAllText(Path.Combine(dataDir, "dbo.Customer_Data.sql"), "-- data");

InitCommand.ConnectionTesterOverride = new StubConnectionTester(true, null);
try
{
// "y" confirms directory; empty lines for connection prompts (defaults); "y" confirms tracked tables.
Console.SetIn(new StringReader(
"y" + Environment.NewLine + // confirm directory
Environment.NewLine + // server → localhost
Environment.NewLine + // database → empty
Environment.NewLine + // auth → integrated
Environment.NewLine + // trust cert → n
"y" + Environment.NewLine)); // confirm tracked tables

var exitCode = Program.Main(["init"]);

Assert.Equal(ExitCodes.Success, exitCode);

var configPath = Path.Combine(tempDir, ConfigFileNames.SqlctConfigFileName);
Assert.True(File.Exists(configPath));

var config = new SqlctConfigReader().Read(configPath);
Assert.True(config.Success);
Assert.Contains("dbo.Customer", config.Config!.Data.TrackedTables, StringComparer.OrdinalIgnoreCase);
}
finally
{
InitCommand.ConnectionTesterOverride = null;
}
}
finally
{
Console.SetIn(originalInput);
Environment.CurrentDirectory = originalCurrentDirectory;
CleanupTempDir(tempDir);
}
}

[Fact]
public void Init_WithoutProjectDir_WithExistingDataFiles_WhenDeclined_ExcludesTrackedTables()
{
var tempDir = CreateTempDir();
var originalCurrentDirectory = Environment.CurrentDirectory;
var originalInput = Console.In;

try
{
Environment.CurrentDirectory = tempDir;
var dataDir = Path.Combine(tempDir, "Data");
Directory.CreateDirectory(dataDir);
File.WriteAllText(Path.Combine(dataDir, "dbo.Customer_Data.sql"), "-- data");

InitCommand.ConnectionTesterOverride = new StubConnectionTester(true, null);
try
{
// "y" confirms directory; empty lines for connection prompts (defaults); "n" declines tracked tables.
Console.SetIn(new StringReader(
"y" + Environment.NewLine + // confirm directory
Environment.NewLine + // server → localhost
Environment.NewLine + // database → empty
Environment.NewLine + // auth → integrated
Environment.NewLine + // trust cert → n
"n" + Environment.NewLine)); // decline tracked tables

var exitCode = Program.Main(["init"]);

Assert.Equal(ExitCodes.Success, exitCode);

var configPath = Path.Combine(tempDir, ConfigFileNames.SqlctConfigFileName);
Assert.True(File.Exists(configPath));

var config = new SqlctConfigReader().Read(configPath);
Assert.True(config.Success);
Assert.Empty(config.Config!.Data.TrackedTables);
}
finally
{
InitCommand.ConnectionTesterOverride = null;
}
}
finally
{
Console.SetIn(originalInput);
Environment.CurrentDirectory = originalCurrentDirectory;
CleanupTempDir(tempDir);
}
}

[Fact]
public void Init_WithProjectDir_WithMalformedDataFileNames_SkipsInvalidFiles()
{
var tempDir = CreateTempDir();

try
{
var projectDir = Path.Combine(tempDir, "project");
var dataDir = Path.Combine(projectDir, "Data");
Directory.CreateDirectory(dataDir);
// Valid file
File.WriteAllText(Path.Combine(dataDir, "dbo.Customer_Data.sql"), "-- data");
// Malformed files (no _Data suffix or no schema separator)
File.WriteAllText(Path.Combine(dataDir, "NotADataFile.sql"), "-- not data");
File.WriteAllText(Path.Combine(dataDir, "missingschema_Data.sql"), "-- no dot");

var exitCode = Program.Main(["init", "--project-dir", projectDir]);

Assert.Equal(ExitCodes.Success, exitCode);

var config = new SqlctConfigReader().Read(SqlctConfigWriter.GetDefaultPath(projectDir));
Assert.True(config.Success);
Assert.Single(config.Config!.Data.TrackedTables);
Assert.Contains("dbo.Customer", config.Config!.Data.TrackedTables, StringComparer.OrdinalIgnoreCase);
}
finally
{
CleanupTempDir(tempDir);
}
}

private static string CreateTempDir()
{
var dir = Path.Combine(Path.GetTempPath(), "sqlct-tests", Guid.NewGuid().ToString("N"));
Expand Down
Loading