Working with Custom Providers
 
Published: 06 Feb 2007
Abstract
Creating your own provider from scratch is a difficult topic to understand. This article will create a provider from the abstract base class, to the configuration needs, to the static class that exposes it, like the other providers in the Microsoft .NET Framework do.
by Brian Mains
Feedback
Average Rating: 
Views (Total / Last 10 Days): 52697/ 51

Introduction

If you are familiar with ASP.NET 2.0, you understand the concept of providers.  Built-in to this version of the framework are the Membership, Roles, and Profile providers, to name a few.  In trying to understand providers are on my own, they turned out to be very complex mechanisms, involving so many different types of parts.  So, I wanted to write this article to show you the concept of creating your own custom provider from scratch.  In order to understand how it all works, you have to understand that with each provider, there are four parts:

·         Abstract class that defines the required properties/methods, and the derived implementations that provide the specific functionality

·         Configuration section that sets up the provider for your .NET application

·         Static class that makes the provider accessible to all

Using the membership provider as an example, you will see that this provider incorporates a membership element that resides as a child of the system.web parent element.  The MembershipProvider abstract class is defined in the System.Web.Security namespace, where all existing and any new providers inherit from.  To make use of this provider, the static Membership class ties in the derived provider types and utilizes the configuration section to set up the providers in the collection, as you will see.   To the user interface, the static class exposes the properties and methods of the default provider, which is specified in the configuration file.  In this way, it is a decoupled approach, as the Membership static class needs to know nothing about your implementation of the abstract class, and comes to find out which provider to use through the configuration file at runtime.

However, specific implementations of the provider approach are coupled in itself; the SqlMembershipProvider class requires the ASPNET standard database that is created by default for new web applications in Visual Studio .NET.  You can also specify a custom SQL database, but the table/stored procedure structure must be the same, which is where the rigidity comes into play.  However, it is possible to create a less-coupled approach based on the design of the provider.  For instance, a provider could implement the base MembershipProvider class and expose the names of the tables or stored procedures, so the developer can use a custom structure of sorts.  There are many possibilities.

Getting back to our custom provider example, the example I will be using is a Newsletter provider, which allows users to subscribe to certain newsletters belonging to a specific application.  An administrator will setup the newsletters that will be provided through the site, as well as other subscriber/newsletter administrative features.  You will see and understand the code in a moment.  Let's start at the beginning: the abstract class.

Part 1: Provider Abstract Class

The cornerstone to this approach is an abstract class that all derived, implementation-specific providers will inherit.  Although not every method in the class is abstract, the methods that are the most important for the operation are abstract and will be overridden in the derived class.  If you look through the abstract methods, they deal with the adding, deleting, updating, and retrieving, and checking the existence of newsletters and subscribers.

Listing 1

public abstract class NewsletterProvider : Nucleo.Web.Providers.ProviderBase
{
      public abstract void AddNewsletter(string newsletterName, 
       string description);
      public abstract void AddSubscription(string subscriberEmail, 
       string newsletterName);
      public abstract string[] FindSubscriber(string emailToMatch, 
       string newsletterName);
      public abstract string[] GetAllNewsletters();
      public abstract string[] GetNewslettersForSubscriber(
       string subscriberEmail);
      public abstract string[] GetSubscribers(string newsletterName);
      public abstract bool NewsletterExists(string newsletterName);
      public abstract void RemoveNewsletter(string newsletterName);
      public abstract void RemoveAllSubscriptions(string subscriberEmail);
      public abstract void RemoveSubscription(string subscriberEmail, 
       string newsletterName);
      public abstract bool SubscriberExists(string subscriberEmail);
      public abstract bool SubscriptionExists(string subscriberEmail, 
       string newsletterName);

 

      protected virtual void ValidateNewsletterName(string newsletterName)
      {
            if (string.IsNullOrEmpty(newsletterName))
                  throw new ArgumentNullException("newsletterName", 
                         "The newsletter name must be provided.");
      }
 
      protected virtual void ValidateSubscriberEmail(string subscriberEmail)
      {
            if (string.IsNullOrEmpty(subscriberEmail))
                  throw new ArgumentNullException("subscriberEmail",
                        "The subscriber email must be provided.");
      }
}

The validation is stored in the abstract class and used directly, because it will be handled the same way in all derivatives.  If for some reason it isn't, these methods declare virtual in the definition, so they can be overridden.

By inheriting from this abstract class, any derived class can be used directly through this class.  This is the benefit of inheritance:  all derived classes coming from the same base class can be referenced as the base class.  So in this instance, any derivatives of the NewsletterProvider class can be referenced as NewsletterProvider, which is an important part of object-oriented programming as you will see soon.

Part 2:  Provider Derived Classes

Why use the provider approach, instead of normal object-oriented design?  The power in this design is the flexibility; for instance, a custom provider implementation works with databases, like SQL Server, Oracle, MySql, and other systems; however, it could also work with alternative file types like XML or Text Files, or anything else you can imagine.  And, because it is the framework pattern, the static class can automatically make use of the default provider without having to know anything of it.  This switch comes from the configuration file, or could be applied at runtime (more on this later).

With that said, we are going to look at the complete example of creating a derived newsletter provider using SQL Server that reads/writes this information to a database.  A custom table stores the information, separated by application name.  Below is the layout of the table; stored procedures have been intentionally left out for brevity:

[dbo].[mains_Newsletters]

·         [NewsletterGuid] [uniqueidentifier]

·         [ApplicationName] [nvarchar](256)

·         [NewsletterName] [nvarchar](256)

·         [Description] [nvarchar](max)

 

[dbo].[mains_NewsletterSubscribers]

·         [NewsletterGuid] [uniqueidentifier]

·         [SubscriberEmail] [nvarchar](256)

Because my code uses the Data Application Block, which is part of Microsoft Enterprise Library January 2006 edition, I can use the same code for multiple databases.  Because you can specify the provider name to work with at the connection string level, the Enterprise Library handles switching between several databases for you.

In this instance, the article uses SQL Server to store newsletter information, as shown below.  The first method to look at is for adding a new newsletter to the database:

Listing 2

public override void AddNewsletter(string newsletterName, 
 string description)
{
      this.ValidateNewsletterName(newsletterName);
      //If the newsletter already exists in the database, throw an error
      if (this.NewsletterExists(newsletterName))
            throw new ArgumentException(@"The newsletter already exists 
                  in the database", "newsletterName");
 
      Database database = 
            DatabaseFactory.CreateDatabase(this.ConnectionStringName);
      DbCommand command = database.GetStoredProcCommand("mains_AddNewsletter");
      database.AddInParameter(command, "ApplicationName", 
            DbType.String, this.ApplicationName);
      database.AddInParameter(command, "NewsletterName", 
            DbType.String, newsletterName);
      database.AddInParameter(command, "Description", DbType.String, description);
      database.AddParameter(command, "RETURN_VALUE", DbType.Int32, 
            ParameterDirection.ReturnValue, null, DataRowVersion.Default, null);
 
      //Get the value returned from the database
      if (database.ExecuteNonQuery(command) == 0)
            throw new DataException("The newsletter could not be added");
}

We won't look at every method, but evaluate the several general categories of methods that there are.  In this insertion example at the beginning, it checks that the newsletter name exists.  If it is null, empty, or already existing, an exception is thrown.  After validation is cleared, the connection is setup, and a command will be run to execute mains_AddNewsletter.  All of the required parameters are provided; the application name is stored at the provider level, and is retrieved through the public property.  At the time of executing the command, if no results have been returned to the caller, an exception is thrown, as at least one entry should have been inserted.  This method is a simple and straightforward approach, using the Data Application Block.

The second category of methods are retrieval methods.  Whereas inserting, updating, and deleting methods all have the same fundamentals, retrieval methods are different slightly.  Examine the GetNewslettersForSubscriber method below:

Listing 3

public override string[] GetNewslettersForSubscriber(string subscriberEmail)
{
      this.ValidateSubscriberEmail(subscriberEmail);
 
      Database database =
             DatabaseFactory.CreateDatabase(this.ConnectionStringName);
      DbCommand command = database.GetStoredProcCommand(
             "mains_GetNewslettersForSubscriber");
      database.AddInParameter(command, "ApplicationName", 
             DbType.String, this.ApplicationName);
      database.AddInParameter(command, "SubscriberEmail", 
             DbType.String, subscriberEmail);
 
      DataSet results = database.ExecuteDataSet(command);
      List<string> newsletters = new List<string>();
      
      if (results != null && results.Tables.Count > 0)
      {
            foreach (DataRow row in results.Tables[0].Rows)
                  newsletters.Add(row["NewsletterName"].ToString());
      }
      else
            throw new DataException("No data was returned from the provider");
 
      return newsletters.ToArray();
}

The differences are with the validation of the results returned, to ensure we have valid a DataTable to work with.  It also uses the ExecuteDataSet method to return a DataSet object with the stored procedure execution results.  If the method executes correctly, even when there are no results returned, a table should exist in that DataSet.  If no table exists or the results are null, then this is an error condition.  Otherwise, we need to get only the name of the newsletter by adding the value in the row to a list, and returning an array form of that list.  The generic List class works very nicely for this, as the ToArray() method handles the conversion to an array very easily.

The last category of methods is the verification methods, which return a boolean value stating whether a specific value exists in the database.  The validation and database connection works the same; however, the boolean result returned is based on whether results were returned from the database.

Listing 4

public override bool SubscriptionExists(string subscriberEmail, 
 string newsletterName)
{
      this.ValidateSubscriberEmail(subscriberEmail);
      this.ValidateNewsletterName(newsletterName);
 
      Database database = 
            DatabaseFactory.CreateDatabase(this.ConnectionStringName);
      DbCommand command = database.GetStoredProcCommand("mains_SubscriptionExists");
      database.AddInParameter(command, "ApplicationName", 
             DbType.String, this.ApplicationName);
      database.AddInParameter(command, "SubscriberEmail", 
            DbType.String, subscriberEmail);
      database.AddInParameter(command, "NewsletterName", 
            DbType.String, newsletterName);
 
      object value = database.ExecuteScalar(command);
      return (value != null && !DBNull.Value.Equals(value));
}

In the example above, a value other than null should have been returned if the value is true.  If not, a false is returned because the value is null or equals DbNull, the .NET representation of null values in the database.

The last method to discuss with this class is the initialization.  Each provider has their own initialization settings.  For instance, database providers need the name of a connection string key in the connectionStrings configuration section.  A text-based provider, such as an XmlProvider, needs the path to an XML file.  The initialization method retrieves the provider-specific configuration values that we need to setup the provider with.  Examine the implementation below:

Listing 5

public override void Initialize(string name, NameValueCollection config)
{
      if (config == null)
            throw new ArgumentNullException("config");
      if (string.IsNullOrEmpty(name))
            name = "AspNetDatabaseNewsletterProvider";
 
      if (string.IsNullOrEmpty(config["description"]))
      {
            config.Remove("description");
            config.Add("description"@"The default Newsletter Provider 
                  using the specified SQL database.");
      }
 
      base.Initialize(name, config);
 
      _connectionStringName = config["connectionStringName"];
      //If the value is null or empty, throw an error
      if (string.IsNullOrEmpty(_connectionStringName))
            throw new ArgumentNullException("config");
      config.Remove("connectionStringName");
      //If it can't be found in the connection strings section
      if (ConfigurationManager.ConnectionStrings[_connectionStringName] == null)
            throw new ArgumentNullException("connectionStringName",
 "The connection string doesn't exist in the configuration file");
 
      //Set the application name to the configuration entry
      base.ApplicationName = config["applicationName"];
      if (string.IsNullOrEmpty(base.ApplicationName)) base.ApplicationName = "/";
      config.Remove("applicationName");
 
      //If any configuration attributes are left, throw an exception
      if (config.Count > 0 && !string.IsNullOrEmpty(config.GetKey(0)))
       throw new ProviderException("There are too many configuration attributes specified");
}

The configuration must exist; however, certain parameters may be missing, and so the Initialize method supplies default values.  In addition, the derived class required additional parameters, such as connectionStringName and applicationName.  If the applicationName attribute is missing, it is defaulted to "/" (or root).  Upon retrieving these values, they are removed from the collection.  At the end, there should be zero configuration properties left.

Part 3: Provider Configuration

We see in the previous initialize method that it uses settings it retrieves from the configuration file.  This is the next part of the provider setup.  To do this, the NewsletterSection exposes a DefaultProvider and Provider collection property to store the name of the default provider, and contains a list of providers that the static class will use (discussed later).  The following NewsletterSection class, inheriting from ConfigurationSection, has these properties:

Listing 6

[
ConfigurationProperty("defaultProvider",
 DefaultValue = "AspNetDatabaseNewsletterProvider"),
StringValidator(MinLength = 1)
]
public override string DefaultProvider
{
      get { return (string)this["defaultProvider"]; }
      set { this["defaultProvider"= value; }
}
[ConfigurationProperty("providers")]
public ProviderSettingsCollection Providers
{
      get { return (ProviderSettingsCollection)this["providers"]; }
}

The ProviderSettingsCollection class is a standard .NET framework collection for exposing provider elements in the configuration file.  As with any provider, you need the name of the provider to reference it by, and the type of the provider to implement, which could be located in the App_Code, bin, or global assembly cache.

Listing 7

<newsletters defaultProvider="AspNetDatabaseNewsletterProvider">
      <providers>
            <add name="AspNetDatabaseNewsletterProvider"
 type="Nucleo.Web.Providers.DatabaseNewsletterProvider, Nucleo.Web"
 connectionStringName="NucleoDB" applicationName="Business" />
      </providers>
</newsletters>

Obviously, each provider needs its own custom settings; these settings are regulated by the Initialize method we saw previously.  In that initialize method, it made sure that the connection string name and application name attributes are present.  In this way, these two portions tie in together in this way.

Part 4: Provider Static Class

The static class is the last part to creating a provider.  It is a key component in exposing your provider to the consumers.  This class pulls together all the parts, by using the configuration section to determine which provider is the default provider, creating the list of providers, and executing the methods that are called in the default provider.  The static class is declared with the static keyword, as all methods should be static in this way.  The static method exposes the provider as such:

Listing 8

public static bool NewsletterExists(string newsletterName)
{
      return Newsletter.DefaultProvider.NewsletterExists(newsletterName);
}

In addition, if you desire to have overloaded methods, to provide only some of the parameters needed, you can do so in the static class, such as the AddNewsletter method:

Listing 9

public static void AddNewsletter(string newsletterName)
{
      Newsletter.AddNewsletter(newsletterName, null);
}
public static void AddNewsletter(string newsletterName, string description)
{
      Newsletter.DefaultProvider.AddNewsletter(newsletterName, description);
}

How does the provider get initialized, as well as determining the default provider?  This occurs in the Initialize and InitializeProviders methods.  But before we look at them, we need to understand their importance.  In essence, the providers and default provider are lazy loaded, meaning they are loaded whenever they are accessed.  The following is the definition for the DefaultProvider and Providers properties.  Note that the Initialize method is called.

Listing 10

public static NewsletterProvider DefaultProvider
{
      get
      {
            Newsletter.Initialize();
            return Newsletter._defaultProvider;
      }
}
public static NewsletterProviderCollection Providers
{
      get
      {
            Newsletter.Initialize();
            return Newsletter._providers;
      }
}

In the initialize method, a boolean value tracks whether this method has been called previously.  If not, then a lock is performed in this method, to ensure that no other instance has done anything with these providers.  This is the reason for the second check; to ensure that the default provider is really null, even after the lock is placed.

Listing 11

private static void Initialize()
{
      if (!_initialized)
      {
            if (_defaultProvider == null)
            {
                  lock(_lock)
                  {
                        //Do this again to make sure provider is still null,
                         //according to MSDN
                        if (_defaultProvider == null)
                              Newsletter.InitializeProviders();
                  }
            }
 
            //Set the initialized to true, so this doesn't occur again
            _initialized = true;
      }
}

In the Initialize method, the call was made to initialize the providers in the providers collection.  The ProviderSettingsCollection needs converted into a collection of the actual provider class type (NewsletterProvider derived classes).  In the code below, I have a helper method of my own to assist with this conversion.  The actual conversion takes place in a single line, using ProvidersHelper.InstantiateProviders(section.Providers, providers, typeof(NewsletterProvider)) method call.  ProvidersHelper is already defined in the framework, whereas ProviderHelper is defined in my application.

Listing 12

private static void InitializeProviders()
{
      NewsletterSection section = NewsletterSection.Instance;
      _providers = ProviderHelper.InitializeProviders<NewsletterProviderCollection,
 NewsletterProvider>(section);
 
      _defaultProvider = _providers[section.DefaultProvider];
      if (_defaultProvider == null)
            throw new ProviderException("The provider couldn't be instantiated");
}

Now we can use our static class to do whatever we need to with this framework.  It can be utilized in a web or windows application, as long as all the appropriate references are met.

Part 5: NUnit Tests

NUnit is a testing tool that allows you to write unit tests for your .NET applications.  Although not a cornerstone to creating a custom provider, they are an integral part of validating the correctness of the code, and are a key feature of iterative development methodologies.  In addition to the unit tests, I use a SQL Express database that contains an exact replica of the tables/stored procedures, along with sample data that I can use to conduct more meaningful tests.  The great benefit with SQL Express is that I can declare it to "Copy Always" as a build action, and it overwrites the modified data with the original, ensuring my tests can run every time.

The problem with the database is that it retains the state of the data, so any modifications in code must be managed carefully.  That is why SQL Express works out very nicely, as it works by changing the connection string to the local version copied to the bin folder.  The NUnit tests rely on an unaltered copy of the database, which is why tests can only be run once at a time after building.  Take a look at the tests; below is some of my various unit testing code:

Listing 13

[Test]
public void TestAddNewsletter()
{
      Newsletter.AddNewsletter("My e-Business Weekly""This is about e-Business");
}
[Test]
public void TestAddSubscription()
{
      Newsletter.AddSubscription("df@tvchannel.tv", "Stock Exchange Weekly");
}
[Test]
public void TestFindSubscribers()
{
      List<string> newslettersList = 
             new List<string>(Newsletter.FindSubscriber("yahoo.com", "SE Weekly"));
      Assert.IsNotNull(newslettersList);
      Assert.AreEqual(2, newslettersList.Count);
 
      Assert.IsTrue(newslettersList.Contains("bgmst5@yahoo.com"));
      Assert.IsTrue(newslettersList.Contains("dsfsadf@yahoo.com"));
}
[Test]
public void TestGetAllNewsletters()
{
       //Use list for easier access to data
      List<string> newslettersList = 
            new List<string>(Newsletter.GetAllNewsletters());
      Assert.IsNotNull(newslettersList);
      Assert.AreEqual(4, newslettersList.Count);
 
      Assert.IsTrue(newslettersList.Contains("Business Weekly"));
      Assert.IsTrue(newslettersList.Contains("SE Weekly"));
      Assert.IsTrue(newslettersList.Contains("Forbes 500 Newsletter"));
      Assert.IsTrue(newslettersList.Contains("My e-Business Weekly"));
}
[Test]
public void TestSubscriberExists()
{
      Assert.IsTrue(Newsletter.SubscriberExists("bmains@hotmail.com"));
      Assert.IsFalse(Newsletter.SubscriberExists("failed@yahoo.com"));
}

These various kinds of tests ensure that the results returned are the exact values wanted, and that the insertions that are performed are working and in proper function.  With all the code, you want to ensure that successful cases work correctly.  In addition, because the table has an application name parameter, the tests ensure that no data comes from outside application names.  Successful cases aren't also tested; in addition, the tests verify that the correct error responses occur when we test with invalid data.  For instance, the following tests verify that exceptions are thrown upon duplicate and nullable data:

Listing 14

[Test, ExpectedException(typeof(ArgumentException))]
public void TestAddSubscriptionDuplication()
{
      Newsletter.AddSubscription("asdsadfsdfdsf@gmail.com", "Business Weekly");
}
 
[Test, ExpectedException(typeof(ArgumentNullException))]
public void TestAddSubscriptionNullable()
{
      Newsletter.AddSubscription(null, "Business Weekly");
}

In the above situation, the ExpectedException handles the error checking, which requires that an exception is thrown or the statement is failed.

Part 6: The Future

With this new newsletter framework available, the next step, as you see illustrated in the .NET framework, is to create controls to consume them.  That is out of scope of this article, but needs to be mentioned.  Because we know the abstract interface, a control could utilize whatever functions it needs, and works only by dragging/dropping with minimal impact on the user and a great amount of time saved to the developer.  This is a very powerful feature that I hope to write about in the future.

Conclusion

Providers are highly customized solutions met to meet a particular need that any existing provider, or other component, doesn't compare up to.  If developed with flexibility, custom providers can be very useful, not only in the original application, but in an application framework as well.  The key object types that make up the provider are the abstract class, the derived classes with the implementation-specific logic (for instance, connecting to a database, text file, etc.), the configuration section, and a static class that exposes the provider to the consumer.  The abstract class is a key component, because it makes all of the providers available to the static class.  All of the derived classes can implement their own functionality using the signature of the abstract class, meaning using all of the abstract methods, properties, and events.  The configuration section informs the static class which provider to use as the default.  The static class exposes the default provider to the consumer, being able to switch easily between providers because they all inherit from the same abstract interface.  In addition, custom controls can wrap the functionality inside them, making any interface with the API a simple dragging and dropping of the controls.



User Comments

Title: Source Code   
Name: Satish Nandigam
Date: 2010-10-27 2:43:31 AM
Comment:
Hi This is a nice article . Can please provide the source code for custom Provider for the Newsletter.
thanks,
N.Satish
Title: Thanks   
Name: Anitha T S
Date: 2010-07-26 7:31:51 AM
Comment:
Thank you very much for your article on providers. Your article is easy to read and understand! I am a better because of it. ;)
Title: Question Reply   
Name: Brian Mains
Date: 2009-10-16 2:53:52 PM
Comment:
Hello,

Yes, DefaultProvider doesn't exist within ConfigurationSection; it exists in my custom base class, which I should have posted, but I didn't. My apologies.

In your custom section class, just add:

[ConfigurationProperty("defaultProvider")]
public string DefaultProvider
{
get { return (string)this["defaultProvider"]; }
}

[
ConfigurationProperty("providers", IsDefaultCollection=false),
ConfigurationCollection(ProviderSettingsCollection)
]
public ProviderSettingsCollection Providers
{
get { return (ProviderSettingsCollection)this["providers"]; }
}

That's what exists in my base class, as a helper. You can also download the project at: http://www.codeplex.com/nucleo, which has these files in Nucleo.dll, in the Nucleo.Providers namespace. I will be posting an update to this project soon with updated AJAX controls, but this code hasn't been touched so it will remain the same, if you are interested.
Title: Question   
Name: Mark Toth
Date: 2009-10-16 2:08:41 PM
Comment:
When I derive a class from ConfigurationSection I get the following error for DefaultProvider "no suitable method found to override". Am I missing something?
Title: Thanks   
Name: Mahr G. Mohyuddin
Date: 2009-04-22 9:21:56 AM
Comment:
Well explained, Brian!. Thanks.
Title: Many Thanks   
Name: Linda
Date: 2009-02-11 9:58:01 AM
Comment:
Thank you very much for your article on providers. Your article is easy to read and understand! I am a better because of it. ;)
Title: still confused reply   
Name: Brian
Date: 2008-08-28 8:50:50 AM
Comment:
The static class is a class separate from the rest of the code, which exposes the provider base class to the public. It's responsible for instantiating it.

SO this is something that should be in the same project as the provider, but is a separate class.
Title: still confused :(   
Name: .
Date: 2008-08-28 3:19:45 AM
Comment:
Would have been nice to be able to download code. I'm at a loss as to where to put the static class - whether I put it in the application which is trying to use the providers, or in the provider code itself as a separate class.
Title: good articles   
Name: I LIKE LT
Date: 2007-09-05 9:16:47 PM
Comment:
very good articles
Title: Good   
Name: Bilal Wani
Date: 2007-03-20 7:17:27 AM
Comment:
Nice Article!!!
Title: Patil   
Name: Sandip
Date: 2007-03-15 5:16:26 PM
Comment:
Nice Article!!!

-Sandip Patil
Title: Good   
Name: Ramamuni Reddy
Date: 2007-02-18 11:25:54 PM
Comment:
Hello Brian Mains,
Very Good Article.

With Regrads
Ramamuni reddy Mulapaku
Title: Provider Utility   
Name: Bilal Hadiar [MVP]
Date: 2007-02-06 6:07:46 AM
Comment:
Hello Brian,
It is a well written article, congratulations!

I would like to refer you and all the readers to a utility I created a while ago that helps you generate the skeleton of a provider files in a single button click,
Check it here:
http://bhaidar.net/cs/archive/2006/07/07/376.aspx

Regards
Title: Mr.   
Name: KotiReddy.
Date: 2007-02-06 12:30:15 AM
Comment:
Very Good Article.


Regards,
Koti Reddy. S






Community Advice: ASP | SQL | XML | Regular Expressions | Windows


©Copyright 1998-2024 ASPAlliance.com  |  Page Processed at 2024-03-28 10:48:18 AM  AspAlliance Recent Articles RSS Feed
About ASPAlliance | Newsgroups | Advertise | Authors | Email Lists | Feedback | Link To Us | Privacy | Search