From d68ec913ce7cc3bec12e0441bec427b893751bda Mon Sep 17 00:00:00 2001 From: Dmitry Ratner <6830384+dmitrat@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:14:51 +0300 Subject: [PATCH] Studio 3.1.2: a function and a procedure are objects, not labels Engine 14.0.1, which is the first reason for this release: two planner defects found by using Studio were fixed in the engine the same day, and Studio needed no change for either - it needed the engine. Known issue 27. The sixth folder arrived with WS-21 and nothing else did: a routine's context menu offered one item, Refresh, while the engine has had DROP FUNCTION and DROP PROCEDURE since phase 9d and the catalogue already carried the body the inspector was showing. It now offers View definition and Drop, like every other kind of object. The definition is the whole CREATE, assembled from INFORMATION_SCHEMA.ROUTINES and PARAMETERS: a body without its parameters and return type parses, restores, and is a DIFFERENT routine. The parameter types carry their length and precision for the same reason - DECIMAL(18,2) coming back as DECIMAL is a routine that rounds differently. What the catalogue cannot render is NAMED rather than half-written. TWO THINGS FOUND BESIDE IT, and both were worse than the missing menu item: - the DUMP wrote views, indexes and triggers and no routines at all, so a database with functions dumped to a script that restored without them and said nothing; - the summary line under the tree named five folders while the tree drew six, so a database whose only objects are routines was summarised as having nothing in it. The test that matters is not that a definition appears but that it RUNS BACK: each routine is dropped and the text Studio wrote is executed, and the routine is there again. Red with the parameters removed, red with the dump's routines removed, red with the sixth count reverted. Driven against a fresh build: the menu, the definition tab, the confirmation naming the FUNCTION rather than "routine", and the drop itself. Studio: 1040 green. Co-Authored-By: Claude Opus 5 --- Docs/KnownIssues.md | 8 +- .../ARoutineIsAnObjectLikeAnyOtherTests.cs | 299 ++++++++++++++++++ Tools/OutWit.Database.Studio/CHANGELOG.md | 38 +++ .../Models/DatabaseNode.cs | 13 + .../Models/IndexDraft.cs | 36 +++ .../OutWit.Database.Studio.csproj | 2 +- .../Resources/Strings.en.json | 12 +- .../Resources/Strings.ru.json | 13 +- .../Services/DatabaseDump.cs | 12 + .../Services/DatabaseSession.Ddl.cs | 115 +++++++ .../Services/DdlWriter.cs | 44 +++ .../Services/IDatabaseSession.cs | 11 + .../ViewModels/DatabaseExplorerViewModel.cs | 110 +++++-- 13 files changed, 678 insertions(+), 35 deletions(-) create mode 100644 Tools/OutWit.Database.Studio.Tests/ViewModels/ARoutineIsAnObjectLikeAnyOtherTests.cs diff --git a/Docs/KnownIssues.md b/Docs/KnownIssues.md index e9eb6d5..5080fb8 100644 --- a/Docs/KnownIssues.md +++ b/Docs/KnownIssues.md @@ -1620,6 +1620,12 @@ Studio’s Plan panel does - draws the wrong tree, faithfully. **The renderer is ## 27. Studio: a function or a procedure can only be refreshed +> **FIXED in Studio 3.1.2.** A routine is offered *View definition* and *Drop*, the definition is +> the whole `CREATE` assembled from `ROUTINES` and `PARAMETERS`, and - found beside it - +> **`Dump Database…` had been writing no routines at all**, so a dumped database restored without +> its functions and said nothing. `ARoutineIsAnObjectLikeAnyOtherTests` drops each routine and runs +> the definition Studio wrote back into the database. +> > Studio 3.1.1. A gap rather than a wrong answer. The tree’s context menu offers a routine exactly one item, `Refresh`. The engine has @@ -1628,7 +1634,7 @@ inspector on the right shows it - so both *View definition* and *Drop* are possi offered. Every other kind of object in the tree was given its own menu in 3.1.0; routines were not included. -Until they are: drop a routine by running the statement in a query tab. +Until 3.1.2 the way to drop a routine was to run the statement in a query tab. --- ## Verifying a fix diff --git a/Tools/OutWit.Database.Studio.Tests/ViewModels/ARoutineIsAnObjectLikeAnyOtherTests.cs b/Tools/OutWit.Database.Studio.Tests/ViewModels/ARoutineIsAnObjectLikeAnyOtherTests.cs new file mode 100644 index 0000000..89e61f6 --- /dev/null +++ b/Tools/OutWit.Database.Studio.Tests/ViewModels/ARoutineIsAnObjectLikeAnyOtherTests.cs @@ -0,0 +1,299 @@ +using OutWit.Database.Studio.Models; +using OutWit.Database.Studio.Tests.Helpers; + +namespace OutWit.Database.Studio.Tests.ViewModels; + +/// +/// A function and a procedure are objects in the tree, not labels in it. +/// +/// +/// +/// Known issue 27, found by clicking through 3.1.1. The sixth folder arrived with WS-21 and +/// nothing else did: a routine's context menu offered one item, Refresh, while the engine has +/// had DROP FUNCTION and DROP PROCEDURE since phase 9d and the catalogue already +/// carried the body - the inspector on the right was showing it. +/// +/// +/// And the dump had the same hole, which is worse than a missing menu item: it wrote views, +/// indexes and triggers, so a database with routines dumped to a script that restored without them +/// and said nothing about it. +/// +/// +/// The assertion that matters here is not "a definition is produced" but that the definition RUNS +/// BACK: the routine is dropped and the text Studio wrote is executed, and the routine is there +/// again and still answers. +/// +/// +[TestFixture] +public class ARoutineIsAnObjectLikeAnyOtherTests +{ + #region Constants + + private const string FUNCTION = "CREATE FUNCTION DiscountedTotal(Amount DECIMAL(18,2), Percent INT) " + + "RETURNS DECIMAL(18,2) AS BEGIN RETURN (Amount - ((Amount * Percent) / 100)); END"; + + private const string PROCEDURE = "CREATE PROCEDURE ArchiveOld() AS BEGIN " + + "UPDATE Orders SET Status = 'archived' WHERE Status = 'new'; END"; + + #endregion + + #region Fields + + private StudioFixture m_studio = null!; + + #endregion + + #region Setup + + [SetUp] + public async Task SetUp() + { + m_studio = await StudioFixture.CreateAsync(); + + await m_studio.Database.ExecuteNonQueryAsync(FUNCTION); + await m_studio.Database.ExecuteNonQueryAsync(PROCEDURE); + + await m_studio.Explorer.RefreshAsync(); + } + + [TearDown] + public async Task TearDown() + { + await m_studio.DisposeAsync(); + } + + #endregion + + #region What the tree offers + + [Test] + public void ARoutineIsOfferedItsDefinitionAndItsRemovalTest() + { + Select("DiscountedTotal"); + + var explorer = m_studio.Explorer; + + Assert.Multiple(() => + { + Assert.That(explorer.ShowsViewDefinition, Is.True, "the catalogue holds its body"); + Assert.That(explorer.ShowsDrop, Is.True, "and the engine has DROP FUNCTION"); + + Assert.That(explorer.CanViewDefinition, Is.True); + Assert.That(explorer.CanDropObject, Is.True); + + // What a routine still does NOT have, so that this is not a licence to offer everything. + Assert.That(explorer.ShowsRename, Is.False, "there is no ALTER FUNCTION in this language"); + Assert.That(explorer.ShowsEditData, Is.False); + Assert.That(explorer.ShowsBrowseData, Is.False); + }); + } + + [Test] + public void TheTreeKnowsWhichOfTheTwoEachOneIsTest() + { + Assert.Multiple(() => + { + Assert.That(Node("DiscountedTotal").IsFunction, Is.True); + Assert.That(Node("ArchiveOld").IsFunction, Is.False); + + // A fact rather than a rendering: the label is what the fact is drawn as, not the other + // way round. + Assert.That(Node("DiscountedTotal").Detail, Does.StartWith("function")); + Assert.That(Node("ArchiveOld").Detail, Is.EqualTo("procedure")); + }); + } + + #endregion + + #region The definition runs back + + [Test] + public async Task TheDefinitionOfAFunctionRunsBackIntoTheDatabaseTest() + { + await ItRunsBackAsync("DiscountedTotal", isFunction: true); + } + + [Test] + public async Task TheDefinitionOfAProcedureRunsBackIntoTheDatabaseTest() + { + await ItRunsBackAsync("ArchiveOld", isFunction: false); + } + + /// + /// A function's parameters and return type are part of it. A definition without them parses and + /// creates a DIFFERENT routine, which is the failure this case exists to catch. + /// + [Test] + public async Task TheDefinitionCarriesTheParametersAndTheReturnTypeTest() + { + var definition = await m_studio.Database.GetRoutineDefinitionAsync("DiscountedTotal"); + + Assert.That(definition, Is.Not.Null); + + // The SIGNATURE, not the whole text: the parameter names also appear in the body, so + // «the definition contains Amount» passes on a definition with no parameters at all - + // measured, by removing them. + var signature = definition![..definition.IndexOf("RETURNS", StringComparison.Ordinal)]; + + Assert.Multiple(() => + { + Assert.That(signature, Does.Contain("Amount")); + Assert.That(signature, Does.Contain("Percent")); + Assert.That(signature, Does.Contain("DECIMAL(18,2)"), + "a parameter carries its type WITH its precision - DECIMAL alone restores a " + + "routine that rounds differently from the one in the database"); + + Assert.That(definition, Does.Contain("RETURNS DECIMAL"), "and the function its return type"); + }); + } + + #endregion + + #region Dropping one + + [Test] + public async Task DroppingAFunctionTakesItOutOfTheDatabaseAndTheTreeTest() + { + Select("DiscountedTotal"); + + m_studio.Confirmations.AllowDestructive = true; + + await StudioFixture.PressAsync(m_studio.Explorer.DropObjectCommand); + + Assert.Multiple(() => + { + Assert.That(Routines(), Does.Not.Contain("DiscountedTotal")); + Assert.That(Walk().Any(node => node.Name == "DiscountedTotal"), Is.False, + "and the tree was refreshed"); + }); + } + + [Test] + public async Task DroppingAProcedureTakesItOutTooTest() + { + Select("ArchiveOld"); + + m_studio.Confirmations.AllowDestructive = true; + + await StudioFixture.PressAsync(m_studio.Explorer.DropObjectCommand); + + Assert.That(Routines(), Does.Not.Contain("ArchiveOld")); + } + + /// + /// The other direction: a refused question leaves the routine alone. DROP PROCEDURE and DROP + /// FUNCTION are different statements, and a menu that asks and drops anyway would be worse than + /// one that never offered. + /// + [Test] + public async Task ARefusedQuestionLeavesTheRoutineAloneTest() + { + Select("DiscountedTotal"); + + m_studio.Confirmations.AllowDestructive = false; + + await StudioFixture.PressAsync(m_studio.Explorer.DropObjectCommand); + + Assert.That(Routines(), Does.Contain("DiscountedTotal")); + } + + /// + /// The line under the tree counts what the tree draws. It named five folders while the tree + /// drew six, so a database whose only objects are routines was summarised as empty. + /// + [Test] + public async Task TheSummaryCountsTheSixthFolderTest() + { + await m_studio.Explorer.RefreshAsync(); + + Assert.Multiple(() => + { + Assert.That(m_studio.MainWindow.StatusText, Does.Contain("2 routines")); + + // CONTROL: the same line, still naming what it named before. + Assert.That(m_studio.MainWindow.StatusText, Does.Contain("1 trigger")); + }); + } + + #endregion + + #region The dump + + [Test] + public async Task ADumpCarriesTheRoutinesTest() + { + var script = await Studio.Services.DatabaseDump.WriteAsync( + m_studio.Database, new Studio.Services.DumpOptions()); + + Assert.Multiple(() => + { + Assert.That(script, Does.Contain("CREATE FUNCTION"), "a dump without them restores without them"); + Assert.That(script, Does.Contain("DiscountedTotal")); + + Assert.That(script, Does.Contain("CREATE PROCEDURE")); + Assert.That(script, Does.Contain("ArchiveOld")); + + // CONTROL: the dump is the real one, with the objects that were already written. + Assert.That(script, Does.Contain("CREATE TABLE")); + Assert.That(script, Does.Contain("CREATE TRIGGER")); + }); + } + + #endregion + + #region Tools + + private async Task ItRunsBackAsync(string name, bool isFunction) + { + var definition = await m_studio.Database.GetRoutineDefinitionAsync(name); + + Assert.That(definition, Is.Not.Null, $"{name} has a definition"); + + await m_studio.Database.ExecuteNonQueryAsync( + $"DROP {(isFunction ? "FUNCTION" : "PROCEDURE")} {name}"); + + Assume.That(Routines(), Does.Not.Contain(name), "it is gone before the definition is run"); + + await m_studio.Database.ExecuteNonQueryAsync(definition!); + + Assert.That(Routines(), Does.Contain(name), + "the definition Studio wrote does not restore the routine:" + Environment.NewLine + definition); + } + + private IReadOnlyList Routines() + { + return m_studio.Database.GetRoutinesAsync().GetAwaiter().GetResult() + .Select(routine => routine.Name) + .ToList(); + } + + private DatabaseNode Node(string name) + { + var node = Walk().FirstOrDefault(candidate => + candidate.NodeType == DatabaseNodeType.Routine && candidate.Name == name); + + Assert.That(node, Is.Not.Null, $"the tree has a routine called {name}"); + + return node!; + } + + private void Select(string name) + { + m_studio.Explorer.SelectedNode = Node(name); + } + + private IEnumerable Walk() + { + return m_studio.Explorer.Nodes.SelectMany(Flatten); + } + + private static IEnumerable Flatten(DatabaseNode node) + { + yield return node; + + foreach (var child in node.Children.SelectMany(Flatten)) + yield return child; + } + + #endregion +} diff --git a/Tools/OutWit.Database.Studio/CHANGELOG.md b/Tools/OutWit.Database.Studio/CHANGELOG.md index d3f0c6d..2aefeb0 100644 --- a/Tools/OutWit.Database.Studio/CHANGELOG.md +++ b/Tools/OutWit.Database.Studio/CHANGELOG.md @@ -3,6 +3,44 @@ Studio is versioned separately from the WitDatabase engine and released under its own `studio-v*` tag. The engine's changelog is `/CHANGELOG.md`. +## 3.1.2 + +**Engine: 14.0.1**, and that is the first reason for this release. Two planner defects were found +by using Studio and fixed in the engine the same day: a join condition written `ON right.x = +left.y` was refused outright, and `EXPLAIN` gave the right input’s child the wrong parent - so the +Plan panel drew the wrong tree faithfully. Studio needed no change for either; it needed the +engine. + +### A function and a procedure are objects, not labels + +The sixth folder arrived with WS-21 and nothing else did: a routine’s context menu offered one +item, *Refresh*. It now offers **View definition** and **Drop**, like every other kind of object +in the tree - the engine has had `DROP FUNCTION` and `DROP PROCEDURE` since phase 9d, and the +catalogue already carried the body the inspector was showing. + +The definition is the whole `CREATE`, assembled from `INFORMATION_SCHEMA.ROUTINES` and +`PARAMETERS`: a body without its parameters and return type parses and creates a DIFFERENT +routine. A routine the catalogue cannot render is NAMED rather than half-written, which is the +rule a view and a trigger already followed. + +### And the dump was losing them + +Worse than the missing menu item, and found beside it: **`Dump Database…` wrote views, indexes and +triggers, and no routines at all.** A database with functions dumped to a script that restored +without them and said nothing. The dump now carries them, and names one it cannot render. + +The test that matters is not that a definition appears but that it **runs back**: each routine is +dropped and the text Studio wrote is executed, and the routine is there again. + +### And the line under the tree counts six folders + +It named five - tables, views, indexes, triggers, sequences - while the tree has drawn six since +WS-21, so a database whose only objects are routines was summarised as having nothing in it. + +### Known issues + +Issue 27 of [Docs/KnownIssues.md](../../Docs/KnownIssues.md) is this, and is marked fixed. Issues +25 and 26 went with engine 14.0.1. ## 3.1.1 **Four corrections, three of them to things 3.1.0 itself broke.** They were found the day 3.1.0 diff --git a/Tools/OutWit.Database.Studio/Models/DatabaseNode.cs b/Tools/OutWit.Database.Studio/Models/DatabaseNode.cs index ab953b8..41ec7cc 100644 --- a/Tools/OutWit.Database.Studio/Models/DatabaseNode.cs +++ b/Tools/OutWit.Database.Studio/Models/DatabaseNode.cs @@ -67,6 +67,19 @@ public override DatabaseNode Clone() [Notify] public bool IsExpanded { get; set; } + /// + /// For a : whether it is a function rather than a + /// procedure. Null for every other kind of node. + /// + /// + /// One node type covers both because the tree draws them in one folder, and two DDL keywords sit + /// behind it - DROP FUNCTION and DROP PROCEDURE are different statements, and + /// "function deleted" and "procedure deleted" are different sentences in a language with cases. + /// The alternative, reading it back out of the label, is a rendering answering a question about + /// the schema. + /// + public bool? IsFunction { get; set; } + /// /// A stand-in child, there so that the node draws an expander. /// diff --git a/Tools/OutWit.Database.Studio/Models/IndexDraft.cs b/Tools/OutWit.Database.Studio/Models/IndexDraft.cs index 5098c6f..934d93b 100644 --- a/Tools/OutWit.Database.Studio/Models/IndexDraft.cs +++ b/Tools/OutWit.Database.Studio/Models/IndexDraft.cs @@ -106,3 +106,39 @@ public sealed class TriggerDraft /// public string Body { get; set; } = string.Empty; } + +/// +/// A function or a procedure, as much of one as the catalogue can answer for. +/// +/// +/// The two kinds are one draft because they differ in three places - the keyword, the RETURNS clause +/// and what stands between BEGIN and END - and are otherwise the same object. The engine has had both +/// since phase 9d; nothing outside the tree has ever written one. +/// +public sealed class RoutineDraft +{ + public string Name { get; set; } = string.Empty; + + /// A function returns a value and a procedure does not, which is the whole difference. + public bool IsFunction { get; set; } + + /// What a function returns. Null for a procedure. + public string? ReturnType { get; set; } + + /// The parameters in ordinal order, as name and type. + public IReadOnlyList Parameters { get; set; } = []; + + /// + /// The expression a function returns, or the statements a procedure runs - as the catalogue + /// renders them, without BEGIN and END. + /// + public string Body { get; set; } = string.Empty; +} + +/// One parameter of a routine. +public sealed class RoutineParameterDraft +{ + public string Name { get; set; } = string.Empty; + + public string Type { get; set; } = string.Empty; +} diff --git a/Tools/OutWit.Database.Studio/OutWit.Database.Studio.csproj b/Tools/OutWit.Database.Studio/OutWit.Database.Studio.csproj index 03b2450..7b99040 100644 --- a/Tools/OutWit.Database.Studio/OutWit.Database.Studio.csproj +++ b/Tools/OutWit.Database.Studio/OutWit.Database.Studio.csproj @@ -18,7 +18,7 @@ - 3.1.1 + 3.1.2 WitDatabase Studio OutWit diff --git a/Tools/OutWit.Database.Studio/Resources/Strings.en.json b/Tools/OutWit.Database.Studio/Resources/Strings.en.json index 83498e4..e3b1d44 100644 --- a/Tools/OutWit.Database.Studio/Resources/Strings.en.json +++ b/Tools/OutWit.Database.Studio/Resources/Strings.en.json @@ -784,7 +784,7 @@ "Explorer.Folder.Triggers": "Triggers", "Explorer.Folder.Sequences": "Sequences", "Explorer.Folder.Routines": "Routines", - "Explorer.Summary": "{0}: {1}, {2}, {3}, {4}, {5}", + "Explorer.Summary": "{0}: {1}, {2}, {3}, {4}, {5}, {6}", "Query.Autocommit": "Autocommit", "Query.TransactionOpen": "Transaction open · {0}", "Settings.Theme.System": "System", @@ -889,6 +889,10 @@ "one": "{0} sequence", "other": "{0} sequences" }, + "Count.Routines": { + "one": "{0} routine", + "other": "{0} routines" + }, "Count.Tabs": { "one": "{0} tab", "other": "{0} tabs" @@ -1129,6 +1133,12 @@ "Confirm.Drop.Headline.Sequence": "Delete sequence \"{0}\"? This cannot be undone.", "Explorer.Dropped.Sequence": "Sequence deleted: {0}", "Explorer.DropFailed.Sequence": "Could not delete the sequence: {0}", + "Confirm.Drop.Headline.Function": "Delete function \"{0}\"? This cannot be undone.", + "Explorer.Dropped.Function": "Function deleted: {0}", + "Explorer.DropFailed.Function": "Could not delete the function: {0}", + "Confirm.Drop.Headline.Procedure": "Delete procedure \"{0}\"? This cannot be undone.", + "Explorer.Dropped.Procedure": "Procedure deleted: {0}", + "Explorer.DropFailed.Procedure": "Could not delete the procedure: {0}", "Query.ReadOnly.Banner": "This connection is open read-only - a write will be refused by the engine.", "Query.ReadOnly.Short": "Read-only" } diff --git a/Tools/OutWit.Database.Studio/Resources/Strings.ru.json b/Tools/OutWit.Database.Studio/Resources/Strings.ru.json index 1ad341f..9d8e117 100644 --- a/Tools/OutWit.Database.Studio/Resources/Strings.ru.json +++ b/Tools/OutWit.Database.Studio/Resources/Strings.ru.json @@ -797,7 +797,7 @@ "Explorer.Folder.Triggers": "Триггеры", "Explorer.Folder.Sequences": "Последовательности", "Explorer.Folder.Routines": "Процедуры и функции", - "Explorer.Summary": "{0}: {1}, {2}, {3}, {4}, {5}", + "Explorer.Summary": "{0}: {1}, {2}, {3}, {4}, {5}, {6}", "Query.Autocommit": "Автофиксация", "Query.TransactionOpen": "Транзакция открыта · {0}", "Settings.Theme.System": "Как в системе", @@ -909,6 +909,11 @@ "few": "{0} последовательности", "many": "{0} последовательностей" }, + "Count.Routines": { + "one": "{0} подпрограмма", + "few": "{0} подпрограммы", + "many": "{0} подпрограмм" + }, "Count.Tabs": { "one": "{0} вкладка", "few": "{0} вкладки", @@ -1153,6 +1158,12 @@ "Confirm.Drop.Headline.Sequence": "Удалить последовательность «{0}»? Отменить будет нельзя.", "Explorer.Dropped.Sequence": "Последовательность удалена: {0}", "Explorer.DropFailed.Sequence": "Не удалось удалить последовательность: {0}", + "Confirm.Drop.Headline.Function": "Удалить функцию «{0}»? Отменить это будет нельзя.", + "Explorer.Dropped.Function": "Функция удалена: {0}", + "Explorer.DropFailed.Function": "Не удалось удалить функцию: {0}", + "Confirm.Drop.Headline.Procedure": "Удалить процедуру «{0}»? Отменить это будет нельзя.", + "Explorer.Dropped.Procedure": "Процедура удалена: {0}", + "Explorer.DropFailed.Procedure": "Не удалось удалить процедуру: {0}", "Query.ReadOnly.Banner": "Подключение открыто только для чтения — запись будет отклонена движком.", "Query.ReadOnly.Short": "Только чтение" } diff --git a/Tools/OutWit.Database.Studio/Services/DatabaseDump.cs b/Tools/OutWit.Database.Studio/Services/DatabaseDump.cs index 8d30b55..2a48a29 100644 --- a/Tools/OutWit.Database.Studio/Services/DatabaseDump.cs +++ b/Tools/OutWit.Database.Studio/Services/DatabaseDump.cs @@ -215,6 +215,18 @@ private static async Task WriteDefinitionsAsync(IDatabaseSession session, String ? $"-- SKIPPED: the catalogue cannot render trigger {trigger}." : definition.TrimEnd().TrimEnd(';') + ";"); } + + // Functions and procedures, since 3.1.2. They were absent, so a database with routines dumped + // to a script that restored WITHOUT THEM and said nothing - the exact failure the rule above + // exists to prevent, in the one object kind the rule had never been applied to. + foreach (var routine in await session.GetRoutinesAsync(ct)) + { + var definition = await session.GetRoutineDefinitionAsync(routine.Name, ct); + + script.AppendLine(string.IsNullOrEmpty(definition) + ? $"-- SKIPPED: the catalogue cannot render {(routine.IsFunction ? "function" : "procedure")} {routine.Name}." + : definition.TrimEnd().TrimEnd(';') + ";"); + } } #endregion diff --git a/Tools/OutWit.Database.Studio/Services/DatabaseSession.Ddl.cs b/Tools/OutWit.Database.Studio/Services/DatabaseSession.Ddl.cs index 1bdb50a..d6a655d 100644 --- a/Tools/OutWit.Database.Studio/Services/DatabaseSession.Ddl.cs +++ b/Tools/OutWit.Database.Studio/Services/DatabaseSession.Ddl.cs @@ -146,6 +146,121 @@ FROM INFORMATION_SCHEMA.TRIGGERS } } + /// + /// + /// + /// A routine whose body the catalogue cannot render is NAMED by the caller, not written half + /// way - the phase-8 rule, the same one a view and a trigger already follow here. + /// + /// + /// The parameters come from INFORMATION_SCHEMA.PARAMETERS in ordinal order, and the row + /// that describes what a function RETURNS is excluded by IS_RESULT: it is a parameter in + /// the standard’s model and not one in this grammar, where the return type is written after + /// RETURNS. + /// + /// + public async Task GetRoutineDefinitionAsync(string routineName, CancellationToken ct = default) + { + EnsureConnected(); + + try + { + const string sql = @" + SELECT ROUTINE_TYPE, DATA_TYPE, ROUTINE_DEFINITION + FROM INFORMATION_SCHEMA.ROUTINES + WHERE ROUTINE_NAME = @routineName"; + + using var command = m_connection!.CreateCommand(); + command.CommandText = sql; + command.Parameters.AddWithValue("@routineName", routineName); + + string type, body; + string? dataType; + + using (var reader = await command.ExecuteReaderAsync(ct)) + { + if (!await reader.ReadAsync(ct)) + return null; + + type = reader.IsDBNull(0) ? "FUNCTION" : reader.GetString(0); + dataType = reader.IsDBNull(1) ? null : reader.GetString(1); + + if (reader.IsDBNull(2) || string.IsNullOrWhiteSpace(reader.GetString(2))) + return null; + + body = reader.GetString(2); + } + + var isFunction = type.Equals("FUNCTION", StringComparison.OrdinalIgnoreCase); + + // A function with no return type cannot be written: RETURNS is not optional in this + // grammar, and guessing one would produce a routine that is not the one in the database. + if (isFunction && string.IsNullOrWhiteSpace(dataType)) + return null; + + return DdlWriter.CreateRoutine(new RoutineDraft + { + Name = routineName, + IsFunction = isFunction, + ReturnType = dataType, + Parameters = await ReadRoutineParametersAsync(routineName, ct), + Body = body + }); + } + catch (Exception ex) + { + m_logger.LogDebug(ex, "Failed to get routine definition for {RoutineName}", routineName); + return null; + } + } + + /// + /// One routine's parameters, in the order they are declared. + /// + /// + /// Deliberately NOT wrapped in a catch of its own, for the reason + /// gives: a routine written without the parameters it has is + /// a different routine, and the caller's catch reports it as one that cannot be written - which + /// is the honest answer. + /// + internal async Task> ReadRoutineParametersAsync( + string routineName, CancellationToken ct = default) + { + // The length and the precision are read with the type, and composed with it. Without them a + // DECIMAL(18,2) parameter comes back as DECIMAL and a VARCHAR(60) as VARCHAR - a definition + // that parses, restores, and is not the routine that was there. + const string sql = @" + SELECT PARAMETER_NAME, DATA_TYPE, + CHARACTER_MAXIMUM_LENGTH, NUMERIC_PRECISION, NUMERIC_SCALE + FROM INFORMATION_SCHEMA.PARAMETERS + WHERE SPECIFIC_NAME = @routineName + AND IS_RESULT = 'NO' + ORDER BY ORDINAL_POSITION"; + + using var command = m_connection!.CreateCommand(); + command.CommandText = sql; + command.Parameters.AddWithValue("@routineName", routineName); + + using var reader = await command.ExecuteReaderAsync(ct); + + var parameters = new List(); + + while (await reader.ReadAsync(ct)) + { + parameters.Add(new RoutineParameterDraft + { + Name = reader.GetString(0), + Type = ColumnDraft.FormatType( + reader.IsDBNull(1) ? "TEXT" : reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetInt32(2), + reader.IsDBNull(3) ? null : reader.GetInt32(3), + reader.IsDBNull(4) ? null : reader.GetInt32(4)) + }); + } + + return parameters; + } + /// /// The columns one trigger's UPDATE OF names, as the standard publishes them: one row per /// column, and none at all when the trigger watches every column. diff --git a/Tools/OutWit.Database.Studio/Services/DdlWriter.cs b/Tools/OutWit.Database.Studio/Services/DdlWriter.cs index 7a19f4a..152949b 100644 --- a/Tools/OutWit.Database.Studio/Services/DdlWriter.cs +++ b/Tools/OutWit.Database.Studio/Services/DdlWriter.cs @@ -272,6 +272,50 @@ public static string CreateView(string name, string body) => #endregion + #region Routines + + /// + /// A whole CREATE FUNCTION or CREATE PROCEDURE, from what the catalogue answers. + /// + /// + /// + /// The brackets after the name are written even when there are no parameters: this grammar has + /// LPAREN routineParameters? RPAREN, so they are not optional and a function written + /// without them does not parse. + /// + /// + /// A function’s body is one expression and is written after RETURN; a procedure’s is a + /// statement list and stands on its own, exactly as a trigger’s does. + /// + /// + public static string CreateRoutine(RoutineDraft routine) + { + var keyword = routine.IsFunction ? "FUNCTION" : "PROCEDURE"; + + var parameters = string.Join(", ", routine.Parameters + .Select(parameter => $"{Identifier(parameter.Name)} {parameter.Type}")); + + var sb = new StringBuilder($"CREATE {keyword} {Identifier(routine.Name)}({parameters})"); + + if (routine.IsFunction) + sb.Append($" RETURNS {routine.ReturnType}"); + + sb.Append("\nAS BEGIN\n"); + + var body = routine.Body.Trim().TrimEnd(';'); + + sb.Append(Indent(routine.IsFunction ? $"RETURN {body};" : body + ";")); + + sb.Append("\nEND;"); + + return sb.ToString(); + } + + public static string DropRoutine(string name, bool isFunction) => + $"DROP {(isFunction ? "FUNCTION" : "PROCEDURE")} {Identifier(name)};"; + + #endregion + #region Tools private static string Columns(IEnumerable columns) => diff --git a/Tools/OutWit.Database.Studio/Services/IDatabaseSession.cs b/Tools/OutWit.Database.Studio/Services/IDatabaseSession.cs index aa3a5be..3ef15e7 100644 --- a/Tools/OutWit.Database.Studio/Services/IDatabaseSession.cs +++ b/Tools/OutWit.Database.Studio/Services/IDatabaseSession.cs @@ -182,6 +182,17 @@ public interface IDatabaseSession /// Task GetIndexDefinitionAsync(string indexName, CancellationToken ct = default); + /// + /// Gets the definition (DDL) for a function or a procedure - the whole CREATE, assembled + /// from INFORMATION_SCHEMA.ROUTINES and INFORMATION_SCHEMA.PARAMETERS. + /// + /// + /// The catalogue’s ROUTINE_DEFINITION is the body alone, and a body without its + /// parameters and return type is not something that can be run back into a database - the same + /// reason a trigger is assembled here rather than reported as its ACTION_STATEMENT. + /// + Task GetRoutineDefinitionAsync(string routineName, CancellationToken ct = default); + /// /// Gets the definition (DDL) for a table (CREATE TABLE statement). /// diff --git a/Tools/OutWit.Database.Studio/ViewModels/DatabaseExplorerViewModel.cs b/Tools/OutWit.Database.Studio/ViewModels/DatabaseExplorerViewModel.cs index 1594d04..4b84e1f 100644 --- a/Tools/OutWit.Database.Studio/ViewModels/DatabaseExplorerViewModel.cs +++ b/Tools/OutWit.Database.Studio/ViewModels/DatabaseExplorerViewModel.cs @@ -344,6 +344,7 @@ private async Task ViewDefinitionAsync() DatabaseNodeType.Table => await session.GetTableDefinitionAsync(SelectedNode.Name), DatabaseNodeType.View => await session.GetViewDefinitionAsync(SelectedNode.Name), DatabaseNodeType.Trigger => await session.GetTriggerDefinitionAsync(SelectedNode.Name), + DatabaseNodeType.Routine => await session.GetRoutineDefinitionAsync(SelectedNode.Name), DatabaseNodeType.Index => await session.GetIndexDefinitionAsync(SelectedNode.Name), _ => null }; @@ -381,6 +382,11 @@ private async Task DropObjectAsync() DatabaseNodeType.Index => "INDEX", DatabaseNodeType.Trigger => "TRIGGER", DatabaseNodeType.Sequence => "SEQUENCE", + + // Two keywords behind one node type: what the tree calls a routine is a FUNCTION or a + // PROCEDURE, and the node carries which. + DatabaseNodeType.Routine => SelectedNode.IsFunction == true ? "FUNCTION" : "PROCEDURE", + _ => null }; @@ -398,6 +404,10 @@ private async Task DropObjectAsync() // time. Found by running the application, which is the only place it was ever visible. var nodeType = SelectedNode.NodeType; + // Captured with it, and for the same reason: a routine is a function or a procedure, and + // they are two nouns. + var isFunction = SelectedNode.IsFunction == true; + // WS-20. Until 2026-08-10 this method went straight to ExecuteNonQueryAsync: one click in the // tree and the table was gone, while the settings page showed a ticked "ask before dropping an // object". The question is asked through the confirmation service so that the SETTING is @@ -408,7 +418,7 @@ private async Task DropObjectAsync() var proceed = await ApplicationVm.Confirmations.AskAboutDestructiveActionAsync( new DestructiveAction( ConfirmationKind.DroppingObject, - Localization.Format(SentenceKey("Confirm.Drop.Headline", nodeType), objectName), + Localization.Format(SentenceKey("Confirm.Drop.Headline", nodeType, isFunction), objectName), sql, consequences)); @@ -428,13 +438,13 @@ private async Task DropObjectAsync() await RefreshAsync(session); - ApplicationVm.MainWindowVm.StatusText = Localization.Format(SentenceKey("Explorer.Dropped", nodeType), objectName); + ApplicationVm.MainWindowVm.StatusText = Localization.Format(SentenceKey("Explorer.Dropped", nodeType, isFunction), objectName); Logger.LogInformation("Dropped {ObjectType}: {ObjectName} in {Connection}", objectType, objectName, session.DisplayName); } catch (Exception ex) { - ErrorMessage = Localization.Format(SentenceKey("Explorer.DropFailed", nodeType), ex.Message); + ErrorMessage = Localization.Format(SentenceKey("Explorer.DropFailed", nodeType, isFunction), ex.Message); Logger.LogError(ex, "Failed to drop {ObjectType}: {ObjectName}", objectType, objectName); } } @@ -462,28 +472,49 @@ private async Task DropObjectAsync() /// gets one of them wrong. /// /// - private static string SentenceKey(string family, DatabaseNodeType nodeType) => (family, nodeType) switch + private static string SentenceKey(string family, DatabaseNodeType nodeType, bool isFunction = false) { - ("Confirm.Drop.Headline", DatabaseNodeType.Table) => "Confirm.Drop.Headline.Table", - ("Confirm.Drop.Headline", DatabaseNodeType.View) => "Confirm.Drop.Headline.View", - ("Confirm.Drop.Headline", DatabaseNodeType.Index) => "Confirm.Drop.Headline.Index", - ("Confirm.Drop.Headline", DatabaseNodeType.Trigger) => "Confirm.Drop.Headline.Trigger", - ("Confirm.Drop.Headline", DatabaseNodeType.Sequence) => "Confirm.Drop.Headline.Sequence", - - ("Explorer.Dropped", DatabaseNodeType.Table) => "Explorer.Dropped.Table", - ("Explorer.Dropped", DatabaseNodeType.View) => "Explorer.Dropped.View", - ("Explorer.Dropped", DatabaseNodeType.Index) => "Explorer.Dropped.Index", - ("Explorer.Dropped", DatabaseNodeType.Trigger) => "Explorer.Dropped.Trigger", - ("Explorer.Dropped", DatabaseNodeType.Sequence) => "Explorer.Dropped.Sequence", - - ("Explorer.DropFailed", DatabaseNodeType.Table) => "Explorer.DropFailed.Table", - ("Explorer.DropFailed", DatabaseNodeType.View) => "Explorer.DropFailed.View", - ("Explorer.DropFailed", DatabaseNodeType.Index) => "Explorer.DropFailed.Index", - ("Explorer.DropFailed", DatabaseNodeType.Trigger) => "Explorer.DropFailed.Trigger", - ("Explorer.DropFailed", DatabaseNodeType.Sequence) => "Explorer.DropFailed.Sequence", - - _ => family + ".Table" - }; + // A ROUTINE is two nouns behind one node type, so the kind is resolved first and the + // keys stay whole below. + var kind = nodeType switch + { + DatabaseNodeType.View => "View", + DatabaseNodeType.Index => "Index", + DatabaseNodeType.Trigger => "Trigger", + DatabaseNodeType.Sequence => "Sequence", + DatabaseNodeType.Routine => isFunction ? "Function" : "Procedure", + _ => "Table" + }; + + return (family, kind) switch + { + ("Confirm.Drop.Headline", "Table") => "Confirm.Drop.Headline.Table", + ("Confirm.Drop.Headline", "View") => "Confirm.Drop.Headline.View", + ("Confirm.Drop.Headline", "Index") => "Confirm.Drop.Headline.Index", + ("Confirm.Drop.Headline", "Trigger") => "Confirm.Drop.Headline.Trigger", + ("Confirm.Drop.Headline", "Sequence") => "Confirm.Drop.Headline.Sequence", + ("Confirm.Drop.Headline", "Function") => "Confirm.Drop.Headline.Function", + ("Confirm.Drop.Headline", "Procedure") => "Confirm.Drop.Headline.Procedure", + + ("Explorer.Dropped", "Table") => "Explorer.Dropped.Table", + ("Explorer.Dropped", "View") => "Explorer.Dropped.View", + ("Explorer.Dropped", "Index") => "Explorer.Dropped.Index", + ("Explorer.Dropped", "Trigger") => "Explorer.Dropped.Trigger", + ("Explorer.Dropped", "Sequence") => "Explorer.Dropped.Sequence", + ("Explorer.Dropped", "Function") => "Explorer.Dropped.Function", + ("Explorer.Dropped", "Procedure") => "Explorer.Dropped.Procedure", + + ("Explorer.DropFailed", "Table") => "Explorer.DropFailed.Table", + ("Explorer.DropFailed", "View") => "Explorer.DropFailed.View", + ("Explorer.DropFailed", "Index") => "Explorer.DropFailed.Index", + ("Explorer.DropFailed", "Trigger") => "Explorer.DropFailed.Trigger", + ("Explorer.DropFailed", "Sequence") => "Explorer.DropFailed.Sequence", + ("Explorer.DropFailed", "Function") => "Explorer.DropFailed.Function", + ("Explorer.DropFailed", "Procedure") => "Explorer.DropFailed.Procedure", + + _ => family + ".Table" + }; + } /// /// What breaks if this object goes, in the user's language (WS-20). @@ -819,6 +850,9 @@ public async Task RefreshAsync(IDatabaseSession session) node.Detail = routine.IsFunction ? $"function -> {routine.DataType}" : "procedure"; + + // Which of the two it is, kept as a fact rather than read back out of the label. + node.IsFunction = routine.IsFunction; node.ChildrenLoaded = true; } @@ -854,11 +888,17 @@ public async Task RefreshAsync(IDatabaseSession session) Localization.Plural("Count.Views", views.Count), Localization.Plural("Count.Indexes", indexes.Count), Localization.Plural("Count.Triggers", triggers.Count), - Localization.Plural("Count.Sequences", sequences.Count)); + Localization.Plural("Count.Sequences", sequences.Count), + + // The sixth folder counts here too. It did not until 3.1.2: the tree drew six + // folders and this line named five, so a database whose only objects were + // routines was summarised as having nothing in it. + Localization.Plural("Count.Routines", routines.Count)); Logger.LogInformation( - "Explorer refreshed {Connection}: {Tables} tables, {Views} views, {Indexes} indexes, {Triggers} triggers, {Sequences} sequences", - session.DisplayName, tables.Count, views.Count, indexes.Count, triggers.Count, sequences.Count); + "Explorer refreshed {Connection}: {Tables} tables, {Views} views, {Indexes} indexes, {Triggers} triggers, {Sequences} sequences, {Routines} routines", + session.DisplayName, tables.Count, views.Count, indexes.Count, triggers.Count, sequences.Count, + routines.Count); } catch (Exception ex) { @@ -1249,16 +1289,22 @@ or DatabaseNodeType.SequencesFolder or DatabaseNodeType.View or DatabaseNodeType.Index; + // A ROUTINE is here since 3.1.2, and so is it in ShowsDrop below. The tree gained the + // sixth folder in WS-21 and nothing else did: a function offered one item, Refresh, + // while the engine has DROP FUNCTION and the catalogue already holds the body the + // inspector shows (KnownIssues 27). ShowsViewDefinition = nodeType is DatabaseNodeType.Table or DatabaseNodeType.View or DatabaseNodeType.Trigger - or DatabaseNodeType.Index; + or DatabaseNodeType.Index + or DatabaseNodeType.Routine; ShowsDrop = nodeType is DatabaseNodeType.Table or DatabaseNodeType.View or DatabaseNodeType.Index or DatabaseNodeType.Trigger - or DatabaseNodeType.Sequence; + or DatabaseNodeType.Sequence + or DatabaseNodeType.Routine; // A separator is a rule BETWEEN two groups, so it is drawn only when both sides of it have // something. The items above each learned to hide themselves and the five separators did @@ -1289,12 +1335,14 @@ or DatabaseNodeType.View CanViewDefinition = connected && nodeType is DatabaseNodeType.Table or DatabaseNodeType.View or DatabaseNodeType.Trigger - or DatabaseNodeType.Index; + or DatabaseNodeType.Index + or DatabaseNodeType.Routine; CanDropObject = connected && nodeType is DatabaseNodeType.Table or DatabaseNodeType.View or DatabaseNodeType.Index or DatabaseNodeType.Trigger - or DatabaseNodeType.Sequence; + or DatabaseNodeType.Sequence + or DatabaseNodeType.Routine; // Only a table. ALTER VIEW, ALTER INDEX and ALTER TRIGGER do not exist in this language, so // F2 on any of those would be a button that cannot work.