Thursday, November 19, 2009

eConnect Message Queue vs. COM+ vs. Stored Procedures

A few months ago I wrote about how, for various reasons, I was fed up with the Dynamics GP Web Services and was moving towards writing my own web services working against eConnect. (Click here for that article)

Now the reality is that most of the integrations I write are small Windows Forms or intranet applications run within the local LAN and can directly access the SQL server.  I also wondered about writing GP Add-Ins using the Visual Studio Tools for Dynamics GP. 

In either of these scenarios, not only do I not see the sense of using the Dynamics GP Web Services, but of even using the two most documented methods of accessing eConnect using the COM+ or Message Queue interfaces.  I’ve started looking at using the base stored procedures directly in the database. 

Since each XML node maps one to one to a stored procedure of the same name, I’m able to just use the schema documentation to see what the various parameters mean.  I haven’t put anything into production yet with this, but I’ll let you know how it goes.

Anyone else use eConnect this way?  Let me know.

Sunday, November 1, 2009

Accessing the Dynamics GP Configuration File (Dynamics.exe.config) from a Visual Studio Tools for Dynamics GP Add-In – Part 3

This is Part 3 of a three part article on working with the Dynamics.exe.config .NET configuration file.  with the following articles:

[Click here for the Visual Studio solution used in these articles]

So finally, in this part, we’ll put it all together and demonstrate how to access the Dynamics.exe.config configuration from a Visual Studio Tools for Dynamics GP add-in.  For this, we’ll create a simple “Hello Configuration” add-in.  We’ll attach it to the Additional menu of the SOP Batch Entry form as we might a SOP integration.

There’s really only two parts of this add-in:

  • Initialization where we verify the existence of the configuration section and add it if it’s missing and add a menu item and associated event handler to the Additional menu of the SOP Batch Entry form.
  • Event handler, that in this case simply displays the “Hello Configuration” caption, as stored in the configuration section, and the hypothetical import file name from the same section.

Thus the code consists of a single class file in the HelloConfigurationAddIn project of the solution linked to above.  The project was created using the Microsoft Dynamics GP Add-In template.  A reference is added to the GPAddInConfiguration project.  The entire code for the add-in is as follows:

using System.Configuration;
using GPAddInConfiguration;

namespace HelloConfigurationAddIn
{
    public class GPAddIn : IDexterityAddIn
    {
        public void Initialize()
        {
            // Check to see if the section exists and if not create it.
            this.VerifyConfigurationSection();

            // Add to Additional menu for SOP batch form.
            Dynamics.Forms.SopBatchEntry.AddMenuHandler(HelloConfigurationEvent,"Hello Configuration");
        }

        //  Verifies the existence of the section and adds it if missing.
        void VerifyConfigurationSection()
        {
            // If section doesn't exist create it.
            if (ConfigurationManager.GetSection("GPAddIn") == null)
            {
                // Get the current configuration file for writing.
                Configuration config = ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.None);

                // Create the new section.
                GPAddInSectionHandler section = new GPAddInSectionHandler();
                config.Sections.Add("GPAddIn", section);
                section.SectionInformation.ForceSave = true;

                // Save the section
                config.Save(ConfigurationSaveMode.Full);

                ConfigurationManager.RefreshSection("GPAddIn");
            }
        }

        // Script to handle menu entry callbacks
        void HelloConfigurationEvent(object sender, EventArgs e)
        {
            GPAddInSectionHandler section = (GPAddInSectionHandler)ConfigurationManager.GetSection("GPAddIn");
            MessageBox.Show(section.HelloConfigurationCaption + "\n\nImport File:  " + section.ImportFileName, "Hello Configuration");
        }
    }
}

The Initialize method calls the VerifyConfigurationSection function that checks for the existence of the section in the Dynamics.exe.config configuration, and if missing, adds it with the default values.  Additionally, it adds a menu handler for the Additional menu of the SOP Batch Entry form, passing the HelloConfigurationEvent function as the event handler.

The HelloConfigurationEvent simply opens the GPAddIn section of the configuration and displays the HelloConfigurationCaption and ImportFileName values in a message box.

To properly deploy the add-in you need to copy the HelloConfigurationAddIn assembly DLL to the GP AddIns folder as you would any add-in.  However, you also need to deploy the GPAddInConfiguration assembly to the GP folder that contains the Dynamics.exe.config file.  Configuration handler assemblies must reside in the same folder as the host application.

So give it a whirl and see how changes to the values in the configuration file show up in the message box.  Note that in this case, changes don’t show up until you restart the application.

I hope these posts have inspired you to make use of the power of the configuration file in your Dynamics GP add-ins.  There’s certainly much more you can do once you dig into them.  Let me know what you find.

Accessing the Dynamics GP Configuration File (Dynamics.exe.config) from a Visual Studio Tools for Dynamics GP Add-In – Part 2

This is Part 2 of a three part article on working with the Dynamics.exe.config .NET configuration file.  with the following articles:

[Click here for the Visual Studio solution used in these articles]

Now that we’ve created a configuration section handler, let’s create a little console application to test it out and see some of the things we can do with it.  Note that we’re working with the section handler independently of Dynamics GP.  Section handlers are not tied to a particular applications configuration file, but can be referenced in any application.

In this test we’ll do the following:

  • Attempt to get the section, and if it doesn’t exist create it.
  • Attempt to update the configuration settings on a section object retrieved using the ConfigurationManager objects GetSection object.
  • Show how to update and save changes to the section object.

This application is the ConfigurationSectionTester project in the solution.  It is a simple console application and references the System.configuration and the GPAddInConfiguration project assembly.  Note that the configuration assembly must be deployed in the same folder as the calling application and it’s corresponding configuration file.

I’ve created static functions to perform each of the tasks listed above and call them from the Main function so that it looks as follows:

static void Main(string[] args)
{
    verifyAndGetSection();
    attemptUpdateOfReadonlySection();
    updateSection();

    Console.WriteLine();
    Console.WriteLine("Hit Enter to continue...");
    Console.ReadLine();
}

So let’s look at each of the functions individually.  First the verifyAndGetSection function.

private static void verifyAndGetSection()
{
    GPAddInSectionHandler section;

    Console.WriteLine();
    Console.WriteLine("----------");
    Console.WriteLine("Getting or creating section");

    // Attempt to get section from the static ConfigurationManager object.
    section = (GPAddInSectionHandler)ConfigurationManager.GetSection("GPAddIn");

    // If config section doesn't exist, create the section entry
    // in <configSections> and the
    // related target section in <configuration>.
    if (section == null)
    {
        // Open current configuration file for writing.

        Configuration config = ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.None);
        section = new GPAddInSectionHandler();
        config.Sections.Add("GPAddIn", section);
        section.SectionInformation.ForceSave = true;
        config.Save(ConfigurationSaveMode.Full);
        ConfigurationManager.RefreshSection("GPAddIn");

        Console.WriteLine("New section, {0}, created", section.SectionInformation.Name);
    }
    else
    {
        Console.WriteLine("Section {0} opened.", section.SectionInformation.Name);
    }

    Console.WriteLine();

    Console.WriteLine("Hello Configuration Caption - {0}", section.HelloConfigurationCaption);
    Console.WriteLine("Import File Name - {0}", section.ImportFileName);
}

In this function, we first attempt to retrieve the custom section by calling the ConfigurationManager object’s static GetSection method, passing it the section’s name.  If successful, this returns a read-only copy of the section.  If unsuccessful, it returns null (or Nothing in VB.NET).

If the returned section is null then we open a writeable copy of the configuration using the static OpenExeConfiguration method of the ConfigurationManager object.  Note that the parameter passed to this method indicates whether we’re getting the application or user specific configuration file.

Once we have the configuration object, we can add the new section to the Sections collection, mark it to force a save, and then save the configuration.  Note that all of this code works even if a configuration file doesn’t even exist.  Saving the configuration will create a new configuration file if one doesn’t already exist.

Calling the RefreshSection method forces the new section to be loaded when it is next requested within the application.  Otherwise attempting to load the section using the GetSection method prior to restarting the application will return a null object.

In the second function, attemptUpdateOfReadonlySection, we’ll show what happens if you attempt to update a read-only section object.

private static void attemptUpdateOfReadonlySection()
{
    Console.WriteLine();
    Console.WriteLine("----------");
    Console.WriteLine("Attempting to update a readonly section.");

    // Getting a section using GetSection returns a read-only section.
    GPAddInSectionHandler section = (GPAddInSectionHandler)ConfigurationManager.GetSection("GPAddIn");

    try
    {
        section.ImportFileName = "NewFileName.txt";
    }
    catch (Exception ex)
    {
        Console.WriteLine("Error setting section attribute - {0}", ex.Message);
    }
}

Here, we again attempt to retrieve the section using the ConfigurationManager objects static GetSection method.  GetSection returns a read-only copy of the section.  If you attempt to set any of the properties it will throw an error.  Note that this is true whether or not you intend to ultimately save the update to disk.

In the final function, updateSection, we show how to successfully update a sections properties and then save it to disk.  This is very similar to how we created a new section in the verifyAndGetSection function.

private static void updateSection()
{
    // To get a writeable section you open the configuration using OpenExeConfiguration
    // and the return the section from the Sections collection.
    Configuration config = ConfigurationManager.OpenExeConfiguration(ConfigurationUserLevel.None);
    GPAddInSectionHandler section = (GPAddInSectionHandler)config.Sections["GPAddIn"];

    Console.WriteLine();
    Console.WriteLine("----------");
    Console.WriteLine("Updating a section.");

    section.HelloConfigurationCaption = section.HelloConfigurationCaption + " - Modified";
    section.ImportFileName = "NewFileName.txt";

    // Set ForceSave to true to ensure the section is saved even if not updated
    section.SectionInformation.ForceSave = true;
    config.Save(ConfigurationSaveMode.Full);

    Console.WriteLine();

    Console.WriteLine("Hello Configuration Caption - {0}", section.HelloConfigurationCaption);
    Console.WriteLine("Import File Name - {0}", section.ImportFileName);
}

Here we open the configuration using the static OpenExeConfiguration method of the ConfigurationManager object, again, specifying whether we want the application or user specific version of the configuration.  We then retrieve the section from the Sections collection rather than using the GetSection method.  This gives us a writeable version of the section.  Once we’ve updated the section we then use the Save method of its parent configuration to save it to disk.

When testing this, run it from the command line rather than from the Visual Studio IDE.  When running it from the IDE, the initial state of the configuration file (i.e. no file), is restored each time.

The first time you run this, make sure you should start with no configuration file and your command window should look as follows:

clip_image001[7]

Note that first function is creating a new section and setting the values to their defaults.  When you run it a second time you should see the following:

clip_image001[5]

Here you’ll note that the section is being opened and showing the section values updated from the defaults in the previous run.  Also note the “The configuration is read only” error message when attempting to update the section returned by the GetSection method.

Hopefully this has given you some of the basic ways of using successfully using your configuration handler.  In the Part 3, we’ll tie it all together in a Dynamics GP Add-In.

Saturday, October 31, 2009

Accessing the Dynamics GP Configuration File (Dynamics.exe.config) from a Visual Studio Tools for Dynamics GP Add-In – Part 1

This is Part 1 of a three part article on working with the Dynamics.exe.config .NET configuration file. with the following articles:

[Click here for the Visual Studio solution used in these articles]

So one of the great things I love about .NET is the configuration file. A more flexible file format than the old .ini file and not the registry (need I say more?). That being said, I’ve tended to rely on the simple AppSettings mechanism making it fairly easy to create and manage your settings in C# or VB.NET projects.

I love using them for things like default import file paths, remembering user interface settings, connection strings, URLs and such. Now I know there are other ways to store settings, such as in a database table, but sometimes the configuration file is the best tool for the job.

However, .NET DLLs, for a number of reasons I won’t get into here, don’t inherently have their own configuration files. They must access their parent application’s file. Although you can, in fact, use AppSettings, as long as you modify the host application’s (in this case Dynamics’) configuration file, there are some great reasons to go custom.  And it’s easier than you may think.

Since a Dynamics GP Add-In is a .NET DLL, the configuration file we have access to is the Dynamics.exe.config file. So how do we use it to store our own settings that can be used by our Add-Ins?

Step one in this process is to understand the structure and handling of configuration files.

Configuration files are broken into sections (there are also section groups but we’ll ignore those for this project). A section is simply an XML node off the top level configuration node. There’s really no restrictions on its structure as long as it’s valid XML. In GP 10 there is a single section, shell.

Each section must have a section definition that includes the assembly that contains the configuration handler object that reads that section. Let’s take a look at the Dynamics.exe.config file.

clip_image001

[Click here to download the default Dynamics.exe.config configuration file]

Notice that the first node in the configuration file is the configurationsections node. This is where sections are defined. Notice that here, Dynamics is defining the shell section, which you will note is the next node after the configuration sections node. The section definition consists of the name, which is the name of the top level node for that section, followed by the type, which defines the assembly and handler that reads that section.

So to add our own section to the configuration file, we simply need to add a section definition and then the corresponding section…oh yeah, and write a configuration section handler assembly.

In this example, we’re going to create a fairly. It will have a single node, GPAddIn and two properties, HelloConfigurationCaption for our message box, and ImportFileName for our hypothetical file integration. The properties are going to be implemented as attributes of the GPAddIn node, leaving us without the need to handle sub-nodes.

When all is said and done our modified Dynamics.exe.config file will look like this:

clip_image001[8]

[Click here to download the configuration file with the custom section]

Note that we’ve added a new section definition for the GPAddIn section and the section itself with our two settings as attributes. In the type attribute of the section definition, we have “GPAddInConfiguriation.GPAddInSectionHandler”, the actual class that reads the GPAddIn section, and “GPAddInConfiguration”, the name of the assembly itself.

So now that we can read the configuration file and define a custom section, how do we write the code to read it?

So a couple of basics. The section handler class itself is a class that derives from the System.Configuration.ConfigurationSection class. The actual assembly must be a .NET DLL and must be deployed to the same folder as the parent executable. Note that this means that it is in the Microsoft Dynamics\GP folder, not the AddIns folder that the add-in itself will be deployed to.

So let’s write some code! First things first. The source code for this solution can be downloaded from here.

We’re going to start with the configuration handler DLL project, GPAddInConfiguration. This project is a Class Library project and consists of a single C# class file containing a single class, GPAddInSectionHandler, derived from System.Configuration.ConfigurationSection. You will need to reference the System.Configuration assembly in the project.

The beginning of your class file will look as follows:

using System;
using System.Configuration;

namespace GPAddInConfiguration
{
public class GPAddInSectionHandler : ConfigurationSection
{

Next we’ll define the constructors:

// CustomSection constructor.
public GPAddInSectionHandler()
{
}

public GPAddInSectionHandler(string helloConfigurationCaption, string importFileName)
{
HelloConfigurationCaption = helloConfigurationCaption;
ImportFileName = importFileName;
}

After that we define the configuration attributes. We’re using the declarative method so we don’t need to worry about the configuration property collection or private variables:

[ConfigurationProperty("HelloConfigurationCaption", DefaultValue = "Hello Dynamics GP Configuration", IsRequired = true)]
[StringValidator(MinLength = 1, MaxLength = 60)]
public string HelloConfigurationCaption
{
get
{
return (string)this["HelloConfigurationCaption"];
}
set
{
this["HelloConfigurationCaption"] = value;
}
}

[ConfigurationProperty("ImportFileName", DefaultValue = "importfile.txt", IsRequired = true)]
[StringValidator(InvalidCharacters = " ~!@#$%^&*()[]{}/;'\"\\",
MinLength = 1, MaxLength = 255)]
public string ImportFileName
{
get
{
return (string)this["ImportFileName"];
}
set
{
this["ImportFileName"] = value;
}
}

Here we’ve defined the two configuration properties, HelloConfigurationCaption and ImportFileName. Using the ConfigurationProperty attribute we’ve defined their type, default value and whether or not their required without having to write any of the handling code.

Additionally, using the StringValidator attribute we can define what constitutes a valid value, such as maximum length, and in the case of the ImportFileName, invalid characters.

And that’s it. Configuration handler completed. Obviously there’s a lot more that you can do such as child elements and even complete custom handling of the section XML, but for a basic replacement of AppSettings, this works quite nicely, and I would argue, makes for a more readable configuration file.

Hopefully this has demystified the configuration file and will allow you to move beyond the simple AppSettings object. In Part 2, well take our section handler and run it through its paces in a test application.

Friday, October 30, 2009

Error When Running Dynamics GP SQL Reporting Services Trial Balance Summary or Detail Reports

This post deals with the following error message:

The EXECUTE permission was denied on the object 'smGetMsgString', database 'DYNAMICS', schema 'dbo'.

We recently deployed the Dynamics GP SQL reports to a client.  I think this must be the first client who actually went through all of them because we came across a bit of a bug in the security in the Trial Balance Summary and Detail Reports.

Now admittedly, we diverted somewhat from the standard install by modifying the shared data sources to use a fixed SQL login rather than integrated security but we dutifully added that login to the rpt_poweruser role in each of the company databases and the DYNAMICS database.

When the client went to run the Trial Balance Detail report they got the following error:

image

An error occurred during client rendering.

An error has occurred during report processing.

Query execution failed for dataset 'dsProc'.

The EXECUTE permission was denied on the object 'smGetMsgString', database 'DYNAMICS', schema 'dbo'.

The same error occurred on the Trial Balance Summary report.  In reviewing the permissions on the smGetMsgString stored procedure, I found that only the DYNGRP role int he DYNAMICS database had EXECUTE permissions on it.  After giving the three “rpt_” roles EXECUTE permissions, the report ran fine.

Has anyone else run across this or similar issues on other reports?  Has Microsoft fixed this in a service pack?

Wednesday, October 28, 2009

Dynamics GP/SQL Database Login Disconnect

This article deals with the following error messages in Dynamics GP:

  • “A get/change first operation on table 'coProcess' failed accessing SQL data”
  • “[Microsoft][SQL Native Client][SQL Server]The server principal "LESSONUSER2" is not able to access the database "TWO" under the current security context.”
  • “The user could not be added to one or more databases.”
  • “A get/change first operation on table 'SY_Users_MSTR' failed accessing SQL data”
  • “[Microsoft][SQL Native Client][SQL Server]The server principal "LESSONUSER1" is not able to access the database "DYNAMICS" under the current security context.”
  • “[Microsoft][SQL Native Client][SQL Server]EXECUTE permission denied on object 'zDP_SY01400SS_1', database 'DYNAMICS', schema 'dbo'.”

Dynamics GP tracks users separately from SQL Server.  However, there is a one to one relationship between a Dynamics GP user and a SQL Server login.  Both the Dynamics GP user and corresponding SQL Server login have the same username.  Additionally, when a user is granted access to a company, their SQL login is added as a SQL user in that company’s database as part of the DYNGRP role.  The SQL logins associated with Dynamics GP users also have an associated SQL database user in the DYNAMICS database also a member of the DYNGRP role.

Problems happen when the Dynamics GP users and SQL logins get out sync.  There are five situations when these get out of sync:

  • The Dynamics GP user has been given access to a company in GP but the corresponding SQL login doesn’t have access to that company.
  • The Dynamics GP user doesn’t have access to a company in GP but the corresponding SQL login does have access to the company.
  • The Dynamics GP user doesn’t have access to the DYNAMICS database.
  • The Dynamics GP user’s corresponding SQL login doesn’t have access to the Dynamics database.
  • The Dynamics GP user’s corresponding SQL login isn’t a member of the DYNGRP role in the databases it is supposed to have access to.

First we’ll look at each of these situations individually and how to individually resolve them.  Then we’ll look at a script that will clean up all of them at once.

Dynamics User w/ Missing SQL User

This is the case where a Dynamics GP user has been given access to a company in GP but the corresponding SQL login doesn’t have access to that company.  In this case, the user will receive the following error message when logging into Dynamics GP:

image

Clicking on “More Info” returns the following:

image

“[Microsoft][SQL Native Client][SQL Server]The server principal "LESSONUSER2" is not able to access the database "TWO" under the current security context.”

To resolve this error, go into SQL Management Studio and add the corresponding SQL login to the company database as a member of the DYNGRP role.

SQL User in Company Database w/o Corresponding Dynamics GP User Access

In this case, the Dynamics GP user does not have access to a particular company but the corresponding SQL login does have access.  This causes an error in the User Access form if you attempt to add the user to the offending company as follows:

image

“The user could not be added to one or more databases.”

In this case, going into SQL Management Studio and removing the SQL login’s access to the company database will resolve the error and allow the Dynamics GP user to be given access to the company.

SQL User Doesn’t Have Access to the DYNAMICS Database

In this case, the Dynamics GP user has access to the company database, but doesn’t have access to the DYNAMICS database.  If this happens, the user will see the following error when attempting to login to Dynamics GP.

image

Clicking on “More Info” returns the following:

image

“[Microsoft][SQL Native Client][SQL Server]The server principal "LESSONUSER1" is not able to access the database "DYNAMICS" under the current security context.”

To resolve this error, go into SQL Management Studio and add the corresponding SQL login to the DYNAMICS database as a member of the DYNGRP role.

SQL User in Company Database w/o Membership in DYNGRP SQL Database Role

In this case, the Dynamics GP user has access to a company and their corresponding SQL login has access to the company database, however, they are not a member of the DYNGRP role in the company database.  In this case the user will see the following error when trying to login to the company in question:

 

image

Clicking on “More Info” returns the following:

image

“[Microsoft][SQL Native Client][SQL Server]EXECUTE permission denied on object 'zDP_SY01300F_1', database 'TWO', schema 'dbo'.”

To resolve this, go into SQL Management Studio and add the corresponding SQL login to the DYNGRP in the company database in question.

SQL User Is Not a Member of the DYNGRP Role in the DYNAMICS Database.

In this case, the SQL user is properly setup in the company database and has access to the DYNAMICS database, but is not a member of the DYNGRP role in the DYNAMICS database.  When logging in in this situation, the user will receive the following error:

image

Clicking on “More Info” returns the following:

image

“[Microsoft][SQL Native Client][SQL Server]EXECUTE permission denied on object 'zDP_SY01400SS_1', database 'DYNAMICS', schema 'dbo'.”

To resolve this, go into SQL Management Studio and add the corresponding SQL login to the DYNGRP in the DYNAMICS database.

A Script to Fix it All

So the individual fixes are all well and good, but we recently ran a Reformatter project, and in the process, moving from one SQL server to another, at a client with 50+ users and 230+ companies.  In addition, we were reconfiguring all of their security to take advantage of GP 10’s role based security.  Something we hadn’t done during the original upgrade.

When all was said and done, we ended up with a number of situations where there were various disconnects in the users, mostly SQL logins missing from companies when they should have been there and in companies they shouldn’t.  Fixing all this by hand would have been a nightmare, so we decided to write a script that would fix it.  In this case we had to write a script to write a script (see prior article related to this and how to setup SQL Management Studio to do this).  Here, we not only had to write a script that created a script against each company, but also against each user as follows:

--  Don't forget to set Results to Text in your
--  SQL Management Studio query window.
--
--  Set NOCOUNT on so that row count doesn't show
--  up in the text results.
SET NOCOUNT ON;

--  Common table expression that returns every
--  possible combination of user and company.
WITH UserCompanyCombinations
AS (
    SELECT
            SY01400.USERID
            ,SY01500.CMPANYID
            ,SY01500.INTERID
        FROM SY01400 -- User Master table
            CROSS JOIN SY01500 -- Company Master table
        WHERE SY01400.USERID NOT IN ('sa', 'DYNSA')
)
--  Script to add all users to DYNAMICS database
SELECT
        SQLStatement =
            REPLACE(
'
USE DYNAMICS
GO

PRINT ''ADDING USER {USERNAME} to DYNAMICS database.''

CREATE USER [{USERNAME}] FROM LOGIN [{USERNAME}]
GO

EXEC sp_addrolemember ''DYNGRP'', ''{USERNAME}''
GO

----------

',
            '{USERNAME}', USERID)
    FROM SY01400 -- User Master table
    WHERE USERID NOT IN ('sa', 'DYNSA')

UNION ALL

--  Script to remove users from company databases they shouldn't
--  have access to.

SELECT 
        SQLStatement =
            REPLACE(REPLACE(
'
USE {COMPANYDB}
GO

PRINT ''REMOVING USER {USERNAME} from {COMPANYDB} database.''

DROP USER [{USERNAME}]
GO

----------

',
            '{COMPANYDB}', UserCompanyCombinations.INTERID)
            , '{USERNAME}', UserCompanyCombinations.USERID)
    FROM UserCompanyCombinations
        LEFT JOIN SY60100 -- User Access table
            ON UserCompanyCombinations.USERID = SY60100.USERID
                AND UserCompanyCombinations.CMPANYID = SY60100.CMPANYID
    WHERE SY60100.CMPANYID IS NULL

UNION ALL

--  Script to add users to company databases they should have access to
SELECT
        SQLStatement =
            REPLACE(REPLACE(
'
USE {COMPANYDB}
GO

PRINT ''ADDING USER {USERNAME} to database {COMPANYDB}''

CREATE USER [{USERNAME}] FROM LOGIN [{USERNAME}]
GO

EXEC sp_addrolemember ''DYNGRP'', ''{USERNAME}''
GO

----------

',
            '{COMPANYDB}', SY01500.INTERID)
            , '{USERNAME}', SY60100.USERID)
    FROM SY01500 -- Company Master table
        INNER JOIN SY60100 -- User Access table
            ON SY01500.CMPANYID = SY60100.CMPANYID
    WHERE SY60100.USERID NOT IN ('sa', 'DYNSA')

As you can see this is a union of three separate queries as follows:

  • The first query creates a SQL statement for each user, adds them to the DYNAMICS database and then makes them a member of the DYNGRP role. 
  • The second query creates a SQL statement for each user and company that the Dynamics GP user doesn’t have access to a company as defined by the SY60100 user access table and removes their SQL user from the associated company database.
  • The final query creates a SQL statement for each user and company database that the Dynamics GP users do have access to as defined by the SY60100 user access table, adding the corresponding SQL login to the database as a member of the DYNGRP role.

You’ll note that in all of the queries we’re excluding the “sa” and “DYNSA” users as these are treated as special users in Dynamics GP.  “sa” should always be in the “sysadmin” server role, giving it full access to all databases.  The “DYNSA” user maps to the “dbo” (database owner) user in each database rather than a user of the same name.  This gives the “DYNSA” user special privileges in the database, such as the ability to modify database objects.

When you run the script above you should receive something like the following in the results pane:

USE DYNAMICS
GO

PRINT 'ADDING USER LESSONUSER1 to DYNAMICS database.'

CREATE USER [LESSONUSER1] FROM LOGIN [LESSONUSER1]
GO

EXEC sp_addrolemember 'DYNGRP', 'LESSONUSER1'
GO

----------

USE DYNAMICS
GO

PRINT 'ADDING USER LESSONUSER2 to DYNAMICS database.'

CREATE USER [LESSONUSER2] FROM LOGIN [LESSONUSER2]
GO

EXEC sp_addrolemember 'DYNGRP', 'LESSONUSER2'
GO

----------

USE FOUR
GO

PRINT 'REMOVING USER LESSONUSER2 from FOUR database.'

DROP USER [LESSONUSER2]
GO

----------

USE TWO
GO

PRINT 'ADDING USER LESSONUSER1 to database TWO'

CREATE USER [LESSONUSER1] FROM LOGIN [LESSONUSER1]
GO

EXEC sp_addrolemember 'DYNGRP', 'LESSONUSER1'
GO

----------

USE TWO
GO

PRINT 'ADDING USER LESSONUSER2 to database TWO'

CREATE USER [LESSONUSER2] FROM LOGIN [LESSONUSER2]
GO

EXEC sp_addrolemember 'DYNGRP', 'LESSONUSER2'
GO

----------

USE FOUR
GO

PRINT 'ADDING USER LESSONUSER1 to database FOUR'

CREATE USER [LESSONUSER1] FROM LOGIN [LESSONUSER1]
GO

EXEC sp_addrolemember 'DYNGRP', 'LESSONUSER1'
GO

----------

Note that this is a little quick and dirty and running this script will cause errors when it tries to create users that already exist or drop users that don’t.  Adding some extra code to check for a user’s existence or not wouldn’t be that difficult.

Monday, September 28, 2009

Just Getting It Done

Here’s a link to a great blog entry by Joel Spolsky (Joel on Software) about programmers who just get the job done and delivered.  I’m a great believer in “good enough”.  Not perfectly designed, n-tier architecture, reusable web services, but maintainable code, works, gets the job done and gets delivered.

Enough said.  Read the article.

Friday, September 25, 2009

Stuck Open Payables Voucher

We recently had a client call with two payables voucher that were stuck open.  The amount remaining was $0 but there were no payments associated with it.  Because the amount remaining was $0, they were unable to apply any further payments.  In addition, running Check Links and Reconcile didn’t resolve the situation.

Time to open up Query Analyzer (yes they’re still on SQL 2000).  After some digging, we found that there were Apply records in the PM10200 (apply work table) and the PM20100 (apply open table) for the full amount of the transaction.  Additionally, the date invoice paid off (DINVPDOF) was set and the current transaction amount (CURTRXAM) was set to $0.

The solution was to remove the apply records, clear out the date invoice paid off and set the current transaction amount to the document amount as follows:

--Delete orphanned apply to records from  the
--work table.
DELETE FROM PM10200
     WHERE VENDORID = '<<MyVendorID>>'
         AND APTVCHNM IN ('<<MyVoucherNumber1>>', '<<MyVoucherNumber2>>')

DELETE FROM PM20100
     WHERE VENDORID = '<<MyVendorID>>'
         AND APTVCHNM IN ('<<MyVoucherNumber1>>', '<<MyVoucherNumber2>>')

--Update the PM transaction header to set the
--current amount due (CURTRXAM) to the full
--document amount and the date invoice paid off
--(DINVPDOF) to empty (1/1/1900)
UPDATE PM20000 SET
        CURTRXAM = DOCAMNT
        ,DINVPDOF = '1/1/1900'
    WHERE VENDORID = '<<MyVendorID>>'
        AND VCHRNMBR IN ('<<MyVoucherNumber1>>', '<<MyVoucherNumber2>>')

For good measure, we ran Check Links against the Payables Transaction Logical File after completing this.  It didn’t register any errors with the specific vouchers.

Thursday, August 27, 2009

Dynamics GP Security Console Error - “Access is denied. (Exception from HRESULT: 0x80070005 (E_ACCESSDENIED))”

We recently had to add a user to an app that uses the Dynamics GP web services.  Since the web services seem to only allow you to assign permissions to individual users and not Active Directory groups, I was required to go into the Dynamics GP Security Console.  Sadly this often turns into an adventure in error messages and this time was no different.

First the console errored out at the top level.  A little digging and I discovered that of the three app pools setup by the GP Web Services in IIS, GPWebServicesAppPool, DynamicsSecurityServiceAppPool and DynamicsSecurityAdminServiceAppPool, the first two were using the service account we’d originally setup for this.  The DynamicsSecurityAdminServiceAppPool, however, was using an administrator end user’s account.  Not exactly best practice.

Anyway, figured that changing it to the same account as the other two app pools would do the trick.  And so it seemed.  I was able to open up the Security Console and navigate down to the policies node, usually the mark of a functioning console. 

However, when I went to add the new user to the appropriate application level group, I received an error message when I clicked Apply, “An unexpected error has occurred.  See the event log for further details.”

So into the event log I dove and found the following error:

Access is denied. (Exception from HRESULT: 0x80070005 (E_ACCESSDENIED))

Pretty much left me stuck.  A couple of emails back and forth with MS support and it turns out that during the Web Services installation, another account is created, DynamicsGPAdm.  This is a local account and is apparently given additional privileges beyond those of a local administrator.  I’m guessing that these are to ADAM. 

Of course, I had no idea what the password was as this was setup during the install, but fortunately, this account isn’t used for anything else.  So a quick password change and an update to the app pool and I was back on my way.

However, the complexity (do we really need three separate app pools?) and fragility of the GP Web Services, is one of the main reasons I’ve moved away from using them have moved creating my own web services using eConnect directly.  ADAM may be a great idea in theory, but it has caused me no end of grief.

Monday, July 27, 2009

“Unable to load client print control” Error When Trying to Print from SQL Reporting Services

We’ve seen this error on and off for quite some time but for some reason we’ve had a batch of calls recently regarding it. 

The error occurs when a user attempts to print a report from SQL Server Reporting Services (SSRS) Report Manager using the printer button on the report viewer.  The issue is caused by an update to Windows Server (ActiveX Kill Bits security update) that breaks the printing function in SSRS.

You can read about the update in http://support.microsoft.com/kb/956391.

The easiest fix is to apply SQL Server Service Pack 3.

Friday, July 24, 2009

“A fatal error was encountered” When Applying Service Pack 3 to Dynamics GP 9.0 on Windows Server 2008

First things first.  I would like to give credit to one of our technical consultants, Henry Pollock, for getting to the bottom of this one.

During a recent upgrade, we received the following error when attempting to apply Service Pack 3 on a Dynamics GP 9.0 install on a new Windows Server 2008 box.

clip_image002

Note that the original install of GP 9.0 had gone fine.  A little digging and it turns out you need to disable UAC (User Access Control) on Windows Server 2008 prior to running the service pack install.

If I were a betting man, I’d guess we’d come across a similar issue on Vista workstations.

Monday, July 20, 2009

Calculating Aggregates in Dynamics GP Integration Manager

We recently came across a bit of a kink when developing an accounts payable integration using Integration Manager for a client.  The client was providing us with a single CSV file of expense report line items.  Each line item represented a distribution for an expense report that would contain one or more distributions.

No problems so far.  We created two sources from the same file, header and distributions.  The header source was grouped by Expense Report ID.  With that we were all set except that it turned out the client was unable to supply us with the expense report total in the source file.  Since Integration Manager doesn’t have any facility to aggregate values when grouping we were stuck.  We had no way to set the Purchases amount in the AP document header.

We briefly considered preprocessing the source file to add the required column but decided to explore some of the scripting capabilities we hadn’t used in the past.  20 minutes and 5 lines of VBScript later we’d solved our problem.

The solution is to keep a running total of the distribution amounts as you’re integrating them and then assign that to the AP Purchases amount before saving each document.  To start with we need a global variable to store our running total and need to initialize it to $0.00.  To do this we add the following VBScript to the Before Integration event of the integration:

SetVariable "PurchaseAmount", CCur(0.00)

The SetVariable statement saves a value that can be accessed from any other script in the integration.  Note that we’ve explicitly converted it to a currency amount using CCur(0.00).  Global variables are Variants and will take on the type of their initial value.

Now that we’ve initialized our variable we need to set the running total by capturing the distribution amounts as we process them.  We do this by setting the Debit Amount of the distribution to Use Script and setting the following script”

SetVariable "PurchaseAmount", _
    GetVariable("PurchaseAmount") + _
    SourceFields("Distributions.ExpenseAmount")

CurrentField = SourceFields("Distributions.ExpenseAmount")

The first line is adding the expense amount to the running total.  The GetVariable function is returning the value of the global variable we set using SetVariable.  The second line is setting the Debit Amount to the current expense amount.  Obviously this is a simple example where we’re assuming that the expense amounts are always positive.

Once we’ve got our total for the document we want to assign it to the document Purchases amount.  To do this we go back to our integration level events and assign the following script to the Before Document Commit event that takes place right before saving each AP document:

DestinationFields("Purchases") = _
    GetVariable("PurchaseAmount")
SetVariable "PurchaseAmount", CCur(0.00)

The first line sets the current documents Purchases amount to the running total.  Since we’re not in working within the destination mappings, we set the Purchases field using the DestinationFields object that gives us access to all of the destination fields.  The second line reinitializes the running total to $0.00 for the next document.

So there you have it.  The ability to calculate aggregates and running totals within Integration Manager.  Beyond the obvious AP and AR integrations, I’m sure there are numerous useful and probably less the useful purpose for this.

Aggregate away!

Friday, July 17, 2009

Automating the Transfer of Dynamics GP Databases – Part 4

In parts 1 through 3 we:

In Part 4 we’re going to tie it altogether.  And to do that we’re going old school.  The batch file.  The following batch file will perform the entire process.

ECHO OFF

ECHO Deleting previous backup files from both source and destination folders
DEL "E:\SQLBackups\*.bak"
DEL "E:\SQLBackups2\*.bak"

ECHO Creating backup script
"C:\Program Files\Microsoft SQL Server\90\Tools\Binn\SQLCMD.EXE" -S GP10DEVPLAYPEN -E -i CreateBackupScript.sql -b -o BackupScript.sql

ECHO Creating restore script
"C:\Program Files\Microsoft SQL Server\90\Tools\Binn\SQLCMD.EXE" -S GP10DEVPLAYPEN -E -i CreateRestoreScript.sql -o RestoreScript.sql

ECHO Creating login transfer script - Using the -d parameter to ensure connecting to the master db
"C:\Program Files\Microsoft SQL Server\90\Tools\Binn\SQLCMD.EXE" -S GP10DEVPLAYPEN -E -d master -i CreateLoginTransferScript.sql -o LoginTransferScript.sql

ECHO Backing up databases
"C:\Program Files\Microsoft SQL Server\90\Tools\Binn\SQLCMD.EXE" -S GP10DEVPLAYPEN -E -i BackupScript.sql -b -o Backup.log

ECHO Copy back files to folder accessible to second SQL server or instance.
XCOPY "E:\SQLBackups\*.bak" "E:\SQLBackups2\"

ECHO Stop and start second SQL instance to ensure that all connections have been cleared out
net stop mssql$SQL2005_2
net start mssql$SQL2005_2

ECHO Restoring databases
"C:\Program Files\Microsoft SQL Server\90\Tools\Binn\SQLCMD.EXE" -S GP10DEVPLAYPEN\SQL2005_2 -E -i RestoreScript.sql -b -o Restore.log

ECHO Transferring logins
"C:\Program Files\Microsoft SQL Server\90\Tools\Binn\SQLCMD.EXE" -S GP10DEVPLAYPEN\SQL2005_2 -E -i LoginTransferScript.sql -b -o LoginTransfer.log

It’s a little difficult to read here (better to cut and paste into a text editor) so here’s what it’s doing:

  • Deleting all backup files in both the source and destination folders.  Don’t use folders that are being used by other backup files as they will be deleted as well.
  • Creating the backup, restore and login transfer scripts.  Note that all of the creation scripts are run against the source server.
  • Running the backup script against the source server.
  • Copying all of the backups to the destination server folder.  In a multi-server environment this would presumably be a share on the destination server.
  • This procedure will pick up new company databases and SQL logins used by GP but will not remove deleted ones.
  • Stopping and starting the destination SQL instance.  This is a quick and dirty way to kill any connections to the databases you’re restoring.
  • Restoring the databases to the destination server.
  • Running the logins script.

A few things to take note of:

  • I’m performing this entire operation on single server with two instances of SQL 2005.  However, I am performing all of the actions necessary to do this between two machines.
  • This was written and tested using SQL 2005.  Some of the constructs, particularly common table expressions and the use of SQLCMD.EXE in the batch file won’t work in SQL 2000 and would have to be modified.  My guess is that most of this would work in SQL 2008.
  • I’m assuming integrated logins.  In other words, the logged in user’s AD login or service account that’s running the batch file needs to be a member of the sysadmin role on both SQL server instances.
  • I came across an error in the restore when there was an additional but empty data file associated with a database.  It wasn’t included in the backup and so errored on the MOVE clause in the restore for that file.

The trickiest part of this was developing the restore script as I needed to take into account all of the possible scenarios of the database files.  Certainly, having the same volume and folders structure on your destination environment would simplify that.

Are There Better Ways of Doing This?

That’s always the question isn’t it.  There are a hundred ways to skin a cat.  Was the 8 hours I spent putting all this together worth it?  I certainly learned a few new things.

There are certainly other ways of doing it.  Other technologies that would be interesting to explore in this regard include:

  • SSIS
  • .NET or ASP.NET apps
  • PowerShell.  I haven’t had the chance to play with PowerShell but it’s becoming the default command line tool for most Microsoft products.

for now I’ll leave that exploration to others.

Thursday, July 16, 2009

Automating the Transfer of Dynamics GP Databases – Part 3

In Parts 1 and 2 we created scripts that backup all of the Dynamics GP databases and then restore them to another SQL instance.  Once that’s done we’ll need refresh security.  To do that we need to transfer the SQL logins, except sa, used by Dynamics GP.

The following SQL script creates a login script that can be run against the second SQL to create the logins, including their original passwords and SIDS.

--  Ensure that when running this script using SQLCMD.EXE that you set the
--  database to master.
--
--  Setting NOCOUNT on prevents the row count message from appearing at the bottom.
--  Note that the SQL statements do not include column headers.  This makes
--  the entire output of the results window executable.

SET NOCOUNT ON

IF  EXISTS (
        SELECT *
            FROM sys.objects
            WHERE object_id = OBJECT_ID(N'[dbo].[fn_Hexadecimal]')
                AND type in (N'FN', N'IF', N'TF', N'FS', N'FT'))
    DROP FUNCTION [dbo].[fn_Hexadecimal]

GO
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
-- =============================================
-- Author:        Limey
-- Create date: 7/16/2009
-- Description:   
--   This function converts a varbinary to a
--   hexadecimal string value.
--   It is used to convert a password hash and
--   login SID to a string representation.
-- =============================================
CREATE FUNCTION dbo.fn_Hexadecimal
(
    -- Add the parameters for the function here
    @binvalue varbinary(256)
)
RETURNS varchar (514)
AS
BEGIN
    DECLARE @charvalue varchar (514)
    DECLARE @i int
    DECLARE @length int
    DECLARE @hexstring char(16)
    SELECT @charvalue = '0x'
    SELECT @i = 1
    SELECT @length = DATALENGTH (@binvalue)
    SELECT @hexstring = '0123456789ABCDEF'
    WHILE (@i <= @length)
    BEGIN
      DECLARE @tempint int
      DECLARE @firstint int
      DECLARE @secondint int
      SELECT @tempint = CONVERT(int, SUBSTRING(@binvalue,@i,1))
      SELECT @firstint = FLOOR(@tempint/16)
      SELECT @secondint = @tempint - (@firstint*16)
      SELECT @charvalue = @charvalue +
        SUBSTRING(@hexstring, @firstint+1, 1) +
        SUBSTRING(@hexstring, @secondint+1, 1)
      SELECT @i = @i + 1
    END

    -- Return the result of the function
    RETURN @charvalue
END
GO

--  This common table expression returns all enabled Dynamics GP
--  logins and their properties.
WITH DynamicsGPLogins
AS (
    SELECT
            SID = dbo.fn_Hexadecimal(ServerPrincipals.sid)
            ,UserName = ServerPrincipals.name
            ,DefaultDatabaseName = ServerPrincipals.default_database_name
            ,IsPolicyChecked =
                CASE
                    WHEN SQLLogins.is_policy_checked  = 1 THEN 'ON'
                    ELSE 'OFF'
                END
            ,IsExpirationChecked =
                CASE
                    WHEN SQLLogins.is_expiration_checked  = 1 THEN 'ON'
                    ELSE 'OFF'
                END
            ,PasswordHash = dbo.fn_Hexadecimal(CAST(LOGINPROPERTY(ServerPrincipals.name, 'PasswordHash') AS varbinary (256)))
        FROM sys.server_principals AS ServerPrincipals
            LEFT JOIN sys.syslogins AS Logins
                ON Logins.name = ServerPrincipals.name
            LEFT JOIN sys.sql_logins AS SQLLogins
                ON ServerPrincipals.name = SQLLogins.name
        WHERE ServerPrincipals.type IN ( 'S', 'G', 'U' )
            AND ISNULL(Logins.hasaccess, 0) = 1
            AND ISNULL(Logins.denylogin, 1) = 0
            AND ISNULL(ServerPrincipals.is_disabled, 1) = 0
            AND ServerPrincipals.name <> 'sa'
            AND ServerPrincipals.name IN (
                    SELECT
                            USERID
                        FROM DYNAMICS.dbo.SY01400)
)

--  This select statement uses the list of Dynamics GP
--  logins to create a create login script.
SELECT
'
IF EXISTS (
        SELECT *
        FROM sys.server_principals
        WHERE name = N''' + UserName + ''')
    DROP LOGIN ' + QUOTENAME(UserName) + '
GO

CREATE LOGIN ' + QUOTENAME(UserName) + '
    WITH PASSWORD = ' + PasswordHash + ' HASHED,
    SID = ' + SID + ',
    DEFAULT_DATABASE = ' + DefaultDatabaseName + ',
    CHECK_POLICY = ' + IsPolicyChecked + ',
    CHECK_EXPIRATION = ' + IsExpirationChecked + '
GO
'
    FROM DynamicsGPLogins

There are two basic sections to this query.  The first creates a scalar function in the master database that converts a varbinary to a varchar.  This is used to convert the SID and password hash to a text representation that can be put in the CREATE LOGIN statement.

The second section of the script actually creates the login script.  There are two parts to this are:

  • The common table expression that returns SQL logins associated with Dynamics GP (from the DYNAMICS SY01400 table) and the associated properties.  It filters out any logins that are disabled or have been denied access.
  • The final part is a select that uses this common table expression to actually create the CREATE LOGIN statements.

This is a simplified version of the SQL login transfer script found in Microsoft Knowledgebase Article 918992 and will produce the following SQL script:

---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

IF EXISTS (
        SELECT *
        FROM sys.server_principals
        WHERE name = N'DYNSA')
    DROP LOGIN [DYNSA]
GO

CREATE LOGIN [DYNSA]
    WITH PASSWORD = 0x01008E7655067D77DE8FCC167C184C22B8B47BC3CA3CA2FFA86E HASHED,
    SID = 0x42F411B53282584D96B94C0B09491091,
    DEFAULT_DATABASE = master,
    CHECK_POLICY = OFF,
    CHECK_EXPIRATION = OFF
GO

IF EXISTS (
        SELECT *
        FROM sys.server_principals
        WHERE name = N'LESSONUSER1')
    DROP LOGIN [LESSONUSER1]
GO

CREATE LOGIN [LESSONUSER1]
    WITH PASSWORD = 0x0100F81A338610819AD7298F890F6DF93302BC329D14B0F23D9D HASHED,
    SID = 0xA33F190403906349BA4074FC68B7F883,
    DEFAULT_DATABASE = master,
    CHECK_POLICY = OFF,
    CHECK_EXPIRATION = OFF
GO

IF EXISTS (
        SELECT *
        FROM sys.server_principals
        WHERE name = N'LESSONUSER2')
    DROP LOGIN [LESSONUSER2]
GO

CREATE LOGIN [LESSONUSER2]
    WITH PASSWORD = 0x0100C093A6586BBC58FE0CB74886EC4F0AB442FC60766EF9805E HASHED,
    SID = 0xBF47CE1CB51C1A4CABC2A75980AAF78D,
    DEFAULT_DATABASE = master,
    CHECK_POLICY = OFF,
    CHECK_EXPIRATION = OFF
GO

Note that since this creates logins with the same SIDs, they’ll automatically be associated with database users.

In Part 4 we’ll look at putting this all together.

Automating the Transfer of Dynamics GP Databases – Part 2

In Part 1 we wrote a SQL script that will create a backup script for all of the Dynamics GP databases to help us automate the transfer all of Dynamics GP’s databases to another SQL server or instance.  In Part 2 we’ll do the same for restoring the databases.

First, let’s review the T-SQL for restoring a database.  In this case the DYNAMICS database.

RESTORE DATABASE DYNAMICS
    FROM DISK = 'E:\SQLBackups2\DYNAMICSTransfer.bak'
    WITH RECOVERY,
        MOVE 'GPSDYNAMICSDat.mdf' TO 'E:\SQLData2\GPSDYNAMICSDat.mdf',
        MOVE 'GPSDYNAMICSLog.ldf' TO 'E:\SQLData2\GPSDYNAMICSLog.ldf'

Not too bad but there’s a bit of a kink here.  The MOVE clauses put the the database files in the desired location on the destination instance.  If you're putting the files in the exact same folder structure on the destination as the source then we’re all set and can skip the MOVE clauses and build our RESTORE statements the same way as the backup script as follows:

--  Setting NOCOUNT on prevents the row count message from appearing at the bottom.
--  Note that the SQL statements do not include column headers.  This makes
--  the entire output of the results window executable.

SET NOCOUNT ON

DECLARE @BackupFolder nvarchar(max)
SET @BackupFolder = 'E:\SQLBackups2\'

--  Create statement to backup DYNAMICS database
SELECT
        '
        RESTORE DATABASE DYNAMICS
            FROM DISK = ''' + @BackupFolder + 'DYNAMICSTransfer.bak''
            WITH RECOVERY
        GO
        '

UNION ALL

--  Create statements to backup company databases.
SELECT
        REPLACE(
        '
        RESTORE DATABASE {COMPANYDB}
            FROM DISK = ''' + @BackupFolder + '{COMPANYDB}Transfer.bak''
            WITH RECOVERY
        GO
        ',
        '{COMPANYDB}', LTRIM(RTRIM(INTERID)))
    FROM DYNAMICS.dbo.SY01500

 

If, as in many cases, your folder structure is different on the machine you’re transferring to, you need to deal with the MOVE clause.  To do this we need to know how many files there are and their logical file names. 

If we were dealing strictly with Dynamics GP Utilities created databases with the default files we could easily just hardcode the two MOVE clauses and insert the company ID in the appropriate places.  However, if you’ve added files to a database (such as spanning it over multiple volumes), or restored one company database over another (such as restoring a production database over a training one for training purposes), your logical file names will not be standard.

The database file information is most easily extracted from the master.sys.master_files system view in the master database.  Once we have the list of files we can build a statement that creates a restore script as follows:

--  Setting NOCOUNT on prevents the row count message from appearing at the bottom.
--  Note that the SQL statements do not include column headers.  This makes
--  the entire output of the results window executable.

SET NOCOUNT ON;

--Declare and set variables for the restore folders
DECLARE @BackupFolder nvarchar(max);
DECLARE @NewDataFolder nvarchar(max);
DECLARE @NewLogsFolder nvarchar(max);

SET @BackupFolder = 'E:\SQLBackups2\';
SET @NewDataFolder = 'E:\SQLData2\';
SET @NewLogsFolder = 'E:\SQLData2\';

--  This common table expression returns the list of Dynamics
--  databases, identifying the min and max file IDs.
WITH Databases
AS
(
    SELECT
            DatabaseID = Databases.database_id
            ,DatabaseName = Databases.name
            ,MinFileID = MIN(DatabaseFiles.file_id)
            ,MaxFileID = MAX(DatabaseFiles.file_id)
        FROM master.sys.databases AS Databases
            INNER JOIN master.sys.master_files AS DatabaseFiles
                ON Databases.database_id = DatabaseFiles.database_id
        WHERE Databases.name = 'DYNAMICS'
            OR Databases.name IN (
                SELECT
                        INTERID
                    FROM DYNAMICS.dbo.SY01500)
        GROUP BY
            Databases.database_id
            ,Databases.name
),

--  This common table expression returns the list of files associated
--  with the Dynamics databases, identifying the first and last files
--  in the list.
DatabaseFiles
AS
(
    SELECT
            DatabaseName = RTRIM(LTRIM(Databases.DatabaseName))
            ,LogicalFileName = DatabaseFiles.name
            ,PhysicalFileName =
                CASE
                    WHEN DatabaseFiles.file_id > 2 THEN
                        CASE
                            WHEN type_desc = 'ROWS' THEN 'GPS'
                                + RTRIM(LTRIM(Databases.DatabaseName))
                                + 'Dat_' + CAST(DatabaseFiles.file_id AS nvarchar(3)) + '.' + SUBSTRING(physical_name, (LEN(physical_name) - 2), 3)
                            ELSE 'GPS' + RTRIM(LTRIM(Databases.DatabaseName)) + 'Log_'
                                + CAST(DatabaseFiles.file_id AS nvarchar(3)) + '.' + SUBSTRING(physical_name, (LEN(physical_name) - 2), 3)
                        END
                    ELSE
                        CASE
                            WHEN type_desc = 'ROWS' THEN 'GPS'
                                + RTRIM(LTRIM(Databases.DatabaseName)) + 'Dat.' + SUBSTRING(physical_name, (LEN(physical_name) - 2), 3)
                            ELSE 'GPS'
                                + RTRIM(LTRIM(Databases.DatabaseName)) + 'Log.' + SUBSTRING(physical_name, (LEN(physical_name) - 2), 3)
                        END
                    END
            ,FileType = type_desc
            ,FileID = DatabaseFiles.file_id
            ,IsFirstFile =
                CASE
                    WHEN DatabaseFiles.file_id = Databases.MinFileID THEN 1
                    ELSE 0
                END
            ,IsLastFile =
                CASE
                    WHEN DatabaseFiles.file_id = Databases.MaxFileID THEN 1
                    ELSE 0
                END
        FROM Databases
            INNER JOIN master.sys.master_files AS DatabaseFiles
                ON Databases.DatabaseID = DatabaseFiles.database_id
)

--  The SELECT statement outputs the appropriate parts of the
--  RESTORE statement depending on whether this the first, last
--  or a middle file.
SELECT
        CASE
            WHEN IsFirstFile = 1 THEN
'
RESTORE DATABASE ' + DatabaseName + '
    FROM DISK = ''' + @BackupFolder + DatabaseName + 'Transfer.bak''
    WITH RECOVERY,
        MOVE ''' + LogicalFileName + ''' TO ''' + @NewDataFolder + PhysicalFileName + ''',
'
            WHEN IsLastFile = 1 THEN
'        MOVE ''' + LogicalFileName + ''' TO ''' + @NewDataFolder + PhysicalFileName + '''
GO
'
            ELSE
'        MOVE ''' + LogicalFileName + ''' TO ''' + @NewDataFolder + PhysicalFileName + ''',
'
        END
    FROM DatabaseFiles
    ORDER BY
        DatabaseName,
        FileID

So this is a little involved and is best read by copying into a query window but essentially we’re doing the following:

  • The first common table expression, Databases, returns a row for each Dynamics database and includes the minimum and maximum file IDs.  This will allow us to know what the first and last files are for a particular database and thus know when to prepend the RESTORE clause at the beginning and the GO statement at the end.
  • The second common table expression, DatabaseFiles, returns the list of database files with their logical names and new physical locations.  Additionally, it indicates whether a particular file is the first or last one in the list.
  • The final SELECT statement actually builds the RESTORE statement using the DatabaseFiles common table expression.

Using my two company test environment, you get the following restore script:

----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

RESTORE DATABASE DYNAMICS
    FROM DISK = 'E:\SQLBackups2\DYNAMICSTransfer.bak'
    WITH RECOVERY,
        MOVE 'GPSDYNAMICSDat.mdf' TO 'E:\SQLData2\GPSDYNAMICSDat.mdf',
        MOVE 'GPSDYNAMICSLog.ldf' TO 'E:\SQLData2\GPSDYNAMICSLog.ldf'
GO

RESTORE DATABASE FOUR
    FROM DISK = 'E:\SQLBackups2\FOURTransfer.bak'
    WITH RECOVERY,
        MOVE 'GPSFOURDat.mdf' TO 'E:\SQLData2\GPSFOURDat.mdf',
        MOVE 'GPSFOURLog.ldf' TO 'E:\SQLData2\GPSFOURLog.ldf',
        MOVE 'FOURDat_2' TO 'E:\SQLData2\GPSFOURDat_3.ndf'
GO

RESTORE DATABASE TWO
    FROM DISK = 'E:\SQLBackups2\TWOTransfer.bak'
    WITH RECOVERY,
        MOVE 'GPSTWODat.mdf' TO 'E:\SQLData2\GPSTWODat.mdf',
        MOVE 'GPSTWOLog.ldf' TO 'E:\SQLData2\GPSTWOLog.ldf'
GO

Note that the FOUR company database has had it’s additional data file properly listed in the MOVE clauses.

If we now run this script against our second server (or server instance), we’ll restore all of the databases we backed up previously.

In our Part 3 we’ll look at how to reattach logins to database users.

Wednesday, July 15, 2009

Automating the Transfer of Dynamics GP Databases – Part 1

Once upon a time I wrote a blog entry on how to write a script that writes a script that runs against every Dynamics GP company database.  Now let’s do something useful with it.

More and more of our clients are having to deal with the alphabet soup regulatory compliance such as SOX (Sarbanes Oxley), PCI DSS, HIPPA etc. or simply deciding to implement good IT change control policies.  Often this means that before deploying that fancy new eConnect integration to production, we need to test it in a separate test environment.

No big deal.  Build out a new server, install SQL, GP, copy over the databases…How many companies did you say you had?  200…huh…Better cancel my dinner plans.

Best of all, you really need to keep that environment refreshed on a pretty regular basis for support purposes.  So how do you transfer 200 company databases (and don’t forget they lowly DYNAMICS database) and still manage to make it home for The Daily Show?

In this four part entry we’ll figure out how to:

Let’s start with backing up.  Time to dig up the dusty Books Online and find out how to backup a database in the query window.  No double clicking here.  Here’s how we’d backup the DYNAMICS database:

BACKUP DATABASE DYNAMICS
    TO DISK = 'E:\SQLBackups\DYNAMICSTransfer.bak'
            WITH FORMAT,
        MEDIANAME = 'DYNAMICSBackup',
        NAME = 'Full Backup of DYNAMICS for Transfer'

So, let’s use our handy dandy code to blow that out so that we build a script that backs up the DYNAMICS database and then each of the company databases.

--  Setting NOCOUNT on prevents the row count message from appearing at the bottom.
--  Note that the SQL statements do not include column headers.  This makes
--  the entire output of the results window executable.

SET NOCOUNT ON

DECLARE @BackupFolder nvarchar(max)
SET @BackupFolder = 'E:\SQLBackups\'

--  Create statement to backup DYNAMICS database
SELECT
        '
        BACKUP DATABASE DYNAMICS
            TO DISK = ''' + @BackupFolder + 'DYNAMICSTransfer.bak''
                    WITH FORMAT,
                MEDIANAME = ''DYNAMICSBackup'',
                NAME = ''Full Backup of DYNAMICS for Transfer''

        GO
        '

UNION ALL

--  Create statements to backup company databases.
SELECT
        REPLACE(
        '
        BACKUP DATABASE {COMPANYDB}
            TO DISK = ''' + @BackupFolder + '{COMPANYDB}Transfer.bak''
                    WITH FORMAT,
                MEDIANAME = ''{COMPANYDB}Backup'',
                NAME = ''Full Backup of {COMPANYDB} for Transfer''

        GO
        ',
        '{COMPANYDB}', LTRIM(RTRIM(INTERID)))
    FROM DYNAMICS.dbo.SY01500

This should produce the following backup script that will backup all of the Dynamics databases:

----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

        BACKUP DATABASE DYNAMICS
            TO DISK = 'E:\SQLBackups\DYNAMICSTransfer.bak'
                    WITH FORMAT,
                MEDIANAME = 'DYNAMICSBackup',
                NAME = 'Full Backup of DYNAMICS for Transfer'

        GO

        BACKUP DATABASE FOUR
            TO DISK = 'E:\SQLBackups\FOURTransfer.bak'
                    WITH FORMAT,
                MEDIANAME = 'FOURBackup',
                NAME = 'Full Backup of FOUR for Transfer'

        GO

        BACKUP DATABASE TWO
            TO DISK = 'E:\SQLBackups\TWOTransfer.bak'
                    WITH FORMAT,
                MEDIANAME = 'TWOBackup',
                NAME = 'Full Backup of TWO for Transfer'

        GO

At this point we’re all set to backup the databases.  In Part 2 we’ll create a script that restores them.