To access SQLCE Compact Framework programmatically, developers can use the managed .NET Data Provider referred to as SqlServerCe. In this section we'll explore the data provider and how it can be used to connect to, query, and update SQLCE, and we'll also show a technique for writing provider-independent code when an application must access both a remote SQL Server and SQLCE.
The SqlServerCe provider was implemented using the same pattern as the SqlClient .NET Data Provider used to access remote SQL Servers, as discussed in the previous chapter; therefore, it consists of the same basic classes. This parity with the desktop provider allows developers to leverage their existing ADO.NET knowledge and begin writing applications for SQLCE. |
Unlike SqlClient, however, the SqlServerCe provider is shipped in an assembly separate from System.Data.dll and, so, must be explicitly referenced by the developer in his or her SDP. Figure 5-4 shows a diagram of the architecture of the provider, all of whose classes are found in the System.Data.SqlServerCe namespace.[7]
[7] All the listings and code snippets in this chapter assume that the System.Data.SqlServerCe and System.Data namespaces have been imported (using C#).
You'll notice in Figure 5-4 that the layout of the classes is similar to that found in Figure 4-4. For example, SqlServerCe supports both the disconnected programming model using the DataSet via the SqlCeDataAdapter object and the connected model using SqlCeDataReader. SQL commands are encapsulated with the SqlCommand class and can use parameters represented by SqlCeParameter. The SqlCeConnection and SqlCeTransaction objects also support local transactions, while database engine errors are captured in SqlCeError objects and thrown using a SqlCeException object. In fact, SqlServerCe even supports the SqlCeCommandBuilder class that can be used to create the INSERT, UPDATE, and DELETE statements automatically for synchronizing data in a data set with SQLCE. However, you'll also notice that SqlServerCe includes the additional classes shown in Table 5-1. These classes are found only in the SqlServerCe provider and have no analogs in SqlClient.
Although SqlCeEngine will be discussed in the following section, both SqlCeReplication and SqlCeRemoteDataAccess used for synchronization will be covered in detail in Chapter 7.
Once the SqlServerCe provider is referenced in an SDP, it can be used to manipulate SQLCE on the device. In this section we'll look at the common tasks developers will need to perform against SQLCE.
Although a database with the appropriate structure and data can be deployed with the application, it is sometimes necessary for developers to create databases and objects on the fly. This can be accomplished using the CreateDatabase method of the SqlCeEngine object. In fact, a good strategy is to encapsulate the creation in a utility class and expose the functionality through shared methods like that shown in Listing 5-1.
Namespace | Use |
---|---|
SqlCeEngine | Includes the methods and properties used to manipulate the SQL Server CE engine directly. |
SqlCeReplication | Allows developers to use merge replication with SQL Server 2000; discussed fully in Chapter 7. |
SqlCeRemoteDataAccess | Allows developers to access a data store remotely and synchronize its data with SQLCE; discussed fully in Chapter 7. |
TIP
If you or your developers do elect to create a utility class to encapsulate common database functionality, you should consider marking the class as sealed (NotInheritable in VB) and giving it a private constructor. In this way, other developers can neither derive from the class nor create public instances of it. All the listings in this section can be thought of as methods in such a data-access utility class.
Public Shared Function CreateDb(ByVal filePath As String) As Boolean ' Delete and create the database Try If File.Exists(filePath) Then File.Delete(filePath) End If Catch e As Exception _lastException = e MsgBox("Could not delete the existing " & filePath, _ MsgBoxStyle.Critical) Return False End Try Dim eng As SqlCeEngine Try eng = New SqlCeEngine("Data Source=" & filePath) eng.CreateDatabase() Return True Catch e As SqlCeException _lastException = e LogSqlError("CreateDb",e) MsgBox("Could not create the database at " & filePath, _ MsgBoxStyle.Critical) Return False Finally eng.Dispose() End Try End Function
In this case you'll notice that the CreateDb method first attempts to delete the database if it exists; it then passes the path to the database to the constructor of SqlCeEngine before calling the CreateDatabase method. The connection string need only consist of the Data Source attribute, and the Provider attribute will be defaulted to Microsoft.SQLSERVER.OLE-DB.CE.2.0.[8] Other attributes may also be used, as discussed later in the chapter.
[8] This differs from ADOCE used in eVB, where omitting the Provider attribute assumes the CEDB provider and not SQLCE.
If an exception is found, the exception is placed in a private variable called _lastException that is exposed as a read-only shared property of the class. In this way the caller can optionally access full information about the exception that occurred. The database error is also logged using a custom method. To use this method the calling code would look like the following (assuming the method was placed in the Atomic.SqlCeUtils class):
If Atomic.SqlCeUtils.CreateDatabase(FileSystem.DocumentsFolder & _ "\Personal\mydb.sdf") Then ' Go ahead and create some tables End If
Note that the calling code uses the FileSystem class shown in Listing 3-5 to retrieve the My Documents folder on the device and then creates the database in the Personal folder.
NOTE
Databases may also be created using the CREATE DATBASE DDL statement when already connected to a different database. This statement also supports password protecting and encrypting the database, as discussed later in the chapter.
To create objects within a database, the application must first create a connection with the SqlCeConnection object. This is easily accomplished by passing the same connection string used to initialize the SqlCeEngine object in Listing 5-1 to the constructor of SqlCeConnection and calling the Open method as follows:
Dim cnCE As New SqlCeConnection(dbConnect) cnCE.Open()
As you would expect, the previous snippet may throw a SqlCeException on either line if the connection string is malformed or the database is already open or does not exist. For this reason the opening of a connection should also be wrapped in a Try-Catch block.
More important, as SQLCE supports only one concurrent connection (unlike SQL Server 2000) because Windows CE is a single-user operating system, the connection object is usually obtained early in the run of the application and persisted in a variable until the application closes. It is therefore important to ensure that the connection eventually gets closed so that other applications (for example, the Query Analyzer) may connect to the database. |
After creating a connection, DDL statements can be executed against the connection to create the appropriate tables and indexes. Each DDL statement must be encapsulated in a SqlCeCommand object and executed with the ExecuteNonQuery method. However, if the application requires that multiple statements be executed (to create several tables and their indexes, for example), it is possible to create a utility function to read the SQL from a resource file deployed with the application. This is accomplished by adding a text file to SDP and setting its Build Action property in the Properties window to Embedded Resource. Then the resource file can be populated with CREATE and ALTER statements, like those shown below, to create a table to hold batting statistics and add a primary key and an index.
CREATE TABLE Batting (Id int NOT NULL, LastName nvarchar(50), FirstName nvarchar(50),Year smallint NOT NULL,Team nchar(3), G smallint NULL,AB smallint NULL,R smallint NULL , H smallint NULL,"2B" smallint NULL,"3B" smallint NULL , HR smallint NULL,RBI smallint NULL); ALTER TABLE Batting ADD CONSTRAINT pk_batting PRIMARY KEY (Id, Year); CREATE INDEX idx_bat_team ON Batting (Year, Team ASC);
When the project is built, the file will then be compiled as a resource in the assembly and deployed to the device.
To read the resource script and execute its DDL, a method like that shown in Listing 5-2 can be written.
Public Shared Function RunScript(ByVal scriptName As String, _ ByVal cn As SqlCeConnection) As Boolean ' Perform a simple execute non query Dim closeIt As Boolean = False Dim resource As Stream Try Resource = _ [Assembly].GetExecutingAssembly().GetManifestResourceStream( _ scriptName)) Dim sr As New StreamReader(resource) Dim script As String = sr.ReadToEnd() Dim commands() As String commands = script.Split(";"c) ' Open the connection if closed If cn.State = ConnectionState.Closed Then cn.Open() closeIt = True End If Dim cm As New SqlCeCommand() cm.Connection = cn Dim s As String For Each s In commands If s <> "" Then cm.CommandText = s cm.ExecuteNonQuery() End If Next ' Clean up Catch e As SqlCeException _lastException = e LogSqlError("RunScript",e) MsgBox("Could not run script " & scriptName, _ MsgBoxStyle.Critical) Return False Catch e As Exception _lastException = e MsgBox("Could not run script " & scriptName, _ MsgBoxStyle.Critical) Return False Finally If closeIt Then cn.Close() End Try Return True End Sub
In Listing 5-2 you'll notice that the GetManifestResourceStream method of the System.Reflection.Assembly class is used to read the resource file into a Stream object. The Stream object is then read by a StreamReader and placed into a string variable. In this scenario, the method is expecting strings delimited with a semicolon and, therefore, creates an array of strings using the Split method. This is required in order to execute multiple statements, because SQLCE does not support batch SQL as SQL Server does. In other words, SQLCE can execute only one statement per SqlCeCommand object.
The method then proceeds to open the connection object if it is closed and create a SqlCeCommand object. The command object is then populated repeatedly in a loop, and each statement is executed using ExecuteNonQuery. You'll notice in the Finally block that the connection is closed only if it were opened by the method. The advantage to this technique is that it allows for looser coupling between the script and the code that executes it, so that the script can be changed without changing any code and the project recompiled and deployed. To put it all together, an application could use code like the following in its main form's Load event to create the database, connect to it, and create tables and indexes:
If Atomic.SqlCeUtils.CreateDatabase(FileSystem.DocumentsFolder & _ "\Personal\mydb.sdf") Then ' Connect (cnCE is global) cnCE = New SqlCeConnection(dbConnect) cnCE.Open() ' Go ahead and create some tables If Atomic.SqlCeUtils.RunScript("firstrun.sql", cnCE) Then ' All is well and the database is ready End If End If
As mentioned previously, SqlServerCe supports both the disconnected and connected programming models using the DataSet and data reader that were discussed in Chapter 4. Unfortunately, unlike in SQL Server 2000, SQLCE does not support stored procedures. As a result, developers will need to formulate SQL within the application and submit it to the database engine (although SQLCE does support parameterized queries, as will be discussed later). Also, as mentioned previously, SQLCE does not support batch SQL, and so, multiple SELECT statements cannot be executed and their results cannot be automatically populated in multiple DataTable objects in a DataSet or through multiple result sets using the NextResult property of the SqlCeDataReader. However, developers can still create data-access helper methods that reduce the amount of code required by the caller. For example, the method in Listing 5-3 adds data to a DataSet based on the SQL passed to the method.
Public Shared Sub FillSimpleDataSet(ByVal ds As DataSet, _ ByVal sql As String, ByVal cn As SqlCeConnection, _ ByVal acceptChanges As Boolean) Try Dim cm As New SqlCeCommand(sql, cn) Dim da As New SqlCeDataAdapter(cm) da.AcceptChangesDuringFill = acceptChanges da.MissingMappingAction = MissingMappingAction.Passthrough da.MissingSchemaAction = MissingSchemaAction.AddWithKey da.Fill(ds) Catch e As SqlCeException LogSqlError("FillSimpleDataSet",e) Throw New SqlCEUtilException( _ "Could not fill dataset for: " & sql, e) End Try End Function
You'll notice that in Listing 5-3 an existing connection object is used and that the caller determines whether AcceptChangesDuringFill is set to True or False to determine if the newly added rows are treated as new rows (with their RowState property set to Added) or as unmodified rows. In this case the connection object needn't be opened explicitly because the SqlCeDataAdapter will open it if it is not already open. The MissingMappingAction and MissingSchemaAction properties are also set to allow the data adapter to create any missing tables or columns in the DataSet and to add primary key information if available. Obviously, this method would not be useful if more sophisticated table mappings were required.[9] If any errors occur, a custom exception of type SqlCeUtilException inherited from ApplicationException is thrown.
[9] See Chapter 12 of Teach Yourself ADO.NET in 21 Days, by Dan Fox, for a complete explanation of how data adapters use table and column mappings.
NOTE
Creating custom exception classes like SqlCeUtilException in Listing 5-3 that can be used to encapsulate application-specific messages and custom methods and properties is a good strategy. The original exception can then be chained to the custom exception using the InnerException property. This technique of exception wrapping, or chaining, allows the application to add specific messages at multiple levels in the call stack.
Data readers can similarly be created to stream through the results from a table as shown in Listing 5-4.
Public Shared Function ExecDataReader(ByVal sql As String, _ ByVal cn As SqlCeConnection) As SqlCeDataReader Try ' Create the command Dim cm As New SqlCeCommand(sql, cn) If cn.State = ConnectionState.Closed Then cn.Open() End If ' Execute data reader Dim dr As SqlCeDataReader dr = cm.ExecuteReader() Return dr Catch e As SqlCeException LogSqlError("ExecDataReader",e) Throw New SqlCEUtilException( _ "Could not execute data reader for :" & sql, e) End Try End Function
In Listing 5-4 the method creates a command object and associates it with the connection passed into the method. In this case the method must also open the connection if it is not already open before executing the data reader and returning it. Note that although the ExecuteReader method supports the CloseConnection and other command behaviors, it is not used because typically a single global database connection remains open for the lifetime of the application.
A caller would then use the method as follows:
Dim dr As SqlCeDataReader dr = SqlCeUtils.ExecDataReader( _ "SELECT * FROM Batting WHERE Id = 660", cnCE) Do While dr.Read() ' Process the data Loop dr.Close()
Although not shown in this listing, it is also interesting to note that unlike the SqlClient provider, the SqlServerCe provider does support multiple data readers on the same open connection object. In other words, developers needn't close the SqlCeDataReader before using the connection to execute another command. Again, this is the case because SQLCE supports only a single concurrent connection.
One of the most interesting new features of SQLCE is the inclusion of parameterized queries. Using parameterized queries, developers can simply populate SqlCeParameter objects associated with a SqlCeCommand, rather than having to manually concatenate parameters into a single string. In addition, parameterized queries are recommended for performance reasons. However, unlike SqlClient, SQLCE supports only positional parameters, and the parameters must be defined in the SQL statement using a question mark. In other words, developers must declare a SqlCeParameter object for each question mark in the SQL statement so that the SqlCeCommand object can perform the substitution at runtime. For example, in order to execute the query shown above as a parameterized query, a developer could do the following:
Dim dr As SqlCeDataReader Dim cm As New SqlCeCommand("SELECT * FROM Batting WHERE Id = ?", cnCE) cm.Parameters.Add(New SqlCeParameter("@Id", SqlDbType.Int)) cm.Parameters(0).Value = 660 dr = SqlCeUtils.ExecDataReader()
In this case, although the parameter was referenced by its ordinal, it could alternatively have been referenced by its name (@Id).
In a helper or utility class, the creation of parameter objects and their association with a command object can be handled by a structure and private method like that shown in Listing 5-5.
Public Structure ParmData Public Name As String Public Value As Object Public DataType As SqlDbType Public Sub New(ByVal name As String, ByVal dataType As SqlDbType, _ ByVal value As Object) Me.Name = name Me.DataType = dataType Me.Value = value End Sub End Structure Private Shared Function PopulateCommand(ByVal sql As String, _ ByVal parms As ArrayList, ByVal cn As SqlCeConnection) _ As SqlCeCommand Dim cm As New SqlCeCommand(sql, cn) cm.CommandType = CommandType.Text ' Populate parameters Dim p As Object For Each p In parms Dim p1 As ParmData = CType(p, ParmData) cm.Parameters.Add( _ New SqlCeParameter(p1.Name, p1.DataType, p1.Value)) Next Return cm End Function
In Listing 5-5 you'll notice that the private PopulateCommand method accepts an ArrayList of ParmData objects as a parameter and uses it to populate a SqlCeCommand created from the SQL statements and SqlCeConnection object passed in as well.[10] With this technique an overloaded version of the method in Listing 5-4 can be created to accept parameterized SQL, as shown in Listing 5-6.
[10] Since SQLCE does not support stored procedures, the StoredProcedure CommandType is also not supported.
Public Shared Function ExecDataReader(ByVal sql As String, _ ByVal cn As SqlCeConnection, _ ByVal parms As ArrayList) As SqlCeDataReader Try ' Create the command Dim cm As SqlCeCommand = Me.PopulateCommand(sql, parms, cn) If cn.State = ConnectionState.Closed Then cn.Open() End If ' Execute data reader Dim dr As SqlCeDataReader dr = cm.ExecuteReader() Return dr Catch e As SqlCeException LogSqlError("ExecDataReader",e) Throw New SqlCEUtilException( _ "Could not execute data reader for :" & sql, e) End Try End Function
At this point the caller need create only the ParmData objects, specifying the appropriate data type, and place them in an ArrayList before passing them to ExecDataReader, as shown in this snippet:
Dim dr As SqlCeDataReader Dim sql As String = "SELECT * FROM Batting WHERE Id = ?" Dim parms As New ArrayList() parms.Add(New ParmData("id", SqlDbType.Int, 660)) dr = SqlCeUtils.ExecDataReader(sql, cnCE, parms)
Perhaps the biggest difference between the SqlClient provider and the SqlServerCe provider is the inclusion of index seeks using data readers in SqlServerCe. Using this technique allows developers to write code that performs better than issuing SELECT statements with WHERE clauses. This is the case because the SQLCE query processor must compile, optimize, and generate a query plan for each query, while performing the index seek directly avoids these costly steps. The caveat is that this works only against single tables, and the table must of course have an index. As a result, for complex queries developers will likely want to rely on the query processor. |
NOTE
In one example documented on Microsoft's SQLCE Web site and referenced in the "Related Reading" section, using an index seek versus the query processor improved performance by a factor of 20 or greater.
For example, consider the scenario where a developer wanted to retrieve the statistics for a specific team and year from the batting table created in Listing 5-2, and it is known that the year will be in the range from 1980 to 1989. The batting table has a composite index on the Year and Team columns, and so a method like that shown in Listing 5-7 can be written to return a SqlCeDataReader positioned on the correct row.
public static SqlCeDataReader ExecTeamReader(SqlCeConnection cn, string team, int year) { SqlCeCommand cmd = new SqlCeCommand("Batting",cn); cmd.CommandType = CommandType.TableDirect; if (cn.State == ConnectionState.Closed) { cn.Open(); } // Index contains Year and Team cmd.IndexName = "idx_bat_team"; object[] start = {1980, 1989}; object[] end = {null, null}; cmd.SetRange(DbRangeOptions.InclusiveStart | DbRangeOptions.InclusiveEnd, start, end); Try { SqlCeDataReader rdr = cmd.ExecuteReader(); rdr.Seek(DbSeekOptions.AfterEqual, year, team); return rdr; } Catch (SqlCeException e) { LogSqlError("ExecTeamReader",e); // Throw a custom exception return null; } }
You'll notice in Listing 5-7 that the SqlCeCommand must have its CommandText property set to the name of the table to search and that the CommandType must be set to TableDirect. The name of the index is then set using the IndexName property. Although it is not required, this listing also shows that the range of values searched can be restricted by passing arrays of start and end values to the SetRange method. The DbRangeOptions enumeration determines how the Seek method uses the start and end values. After opening the data reader using ExecuteReader, its Seek method is then called with a value from the DbSeekOptions enumeration. This value specifies which row if any is to be returned. In this case, AfterEqual is used and if a row is not found, the first row after the index range will be the one pointed to by the data reader. Alternatively, if FirstEqual is used, the Seek method will throw a SqlCeException if a row cannot be located.
A caller can then invoke the method to position a data reader at the statistics for the 1984 Chicago Cubs as follows:
SqlCeDataReader dr = SqlCeUtils.ExecTeamReader(cnCE,"CHN",1984);
Inserting, updating, and deleting data in SQLCE are not handled any differently than they are using the SqlClient provider, with the exception, of course, that SQLCE does not support stored procedures. In other words developers may use the SqlCeDataAdapter to modify data in an underlying base table utilizing the table and column mappings collections and then invoking the Update method of the data adapter. Developers may also execute command objects directly. In either case parameterized queries are used and, in fact, are required for use with the SqlCeDataAdapter.
For example, to insert a new row into the Batting Table, the method shown in Listing 5-8 could be written to return the command object used in either scenario.
Public Shared Function GetBattingCmd(cnCE As SqlCeConnection, _ trans As SqlCeTransaction) As SqlCeCommand Dim sql As String = "INSERT INTO Batting (Id, LastName, " & _ "FirstName, Year, Team, G, AB, R, H, ""2B"", ""3B"", " & _ "HR, RBI) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" battingCmd = New SqlCeCommand(sql) battingCmd.CommandType = CommandType.Text If Not trans Is Nothing Then battingCmd.Transaction = trans End If battingCmd.Parameters.Add(New SqlCeParameter("Id", _ SqlDbType.NVarChar, 9, "Id")) battingCmd.Parameters.Add(New SqlCeParameter("LastName", _ SqlDbType.NVarChar, 50, "LastName")) battingCmd.Parameters.Add(New SqlCeParameter("FirstName", _ SqlDbType.NVarChar, 50, "FirstName")) battingCmd.Parameters.Add(New SqlCeParameter("Year", _ SqlDbType.SmallInt, 4, "Year")) battingCmd.Parameters.Add(New SqlCeParameter("Team", _ SqlDbType.NVarChar, 3, "Team")) battingCmd.Parameters.Add(New SqlCeParameter("G", _ SqlDbType.SmallInt, 4, "G")) battingCmd.Parameters.Add(New SqlCeParameter("AB", _ SqlDbType.SmallInt, 4, "AB")) battingCmd.Parameters.Add(New SqlCeParameter("R", _ SqlDbType.SmallInt, 4, "R")) battingCmd.Parameters.Add(New SqlCeParameter("H", _ SqlDbType.SmallInt, 4, "H")) battingCmd.Parameters.Add(New SqlCeParameter("2B", _ SqlDbType.SmallInt, 4, "2B")) battingCmd.Parameters.Add(New SqlCeParameter("3B", _ SqlDbType.SmallInt, 4, "3B")) battingCmd.Parameters.Add(New SqlCeParameter("HR", _ SqlDbType.SmallInt, 4, "HR")) battingCmd.Parameters.Add(New SqlCeParameter("RBI", _ SqlDbType.SmallInt, 4, "RBI")) return battingCmd End Function
The GetBattingCmd static method could then be used by a caller to retrieve the appropriate command before populating the parameters with values manually through code or by setting it to the InsertCommand property of the SqlCeDataAdapter.
Although not typically recommended for production scenarios with the SqlClient provider,[11] the command builder included with SqlServerCe (SqlCeCommandBuilder) can be used in place of code like that shown in Listing 5-7. This is due to the fact that SQLCE is single user and runs in process with the application; therefore, an extra round trip isn't as costly in terms of performance. In any case, it can be used simply by passing the SqlCeDataAdapter to the constructor of the command builder:
[11] Using the SqlCommandBuilder object always engenders one extra trip to the database server so that the command builder can determine column names and data types.
Dim cb as New SqlCeCommandBuilder(da)
When needed,[12] the command builder will then build the insert, update, and delete commands based on the SELECT statement exposed in the CommandText property of the SelectCommand. Note that just as with the SqlClient provider, the SELECT statement used by the data adapter mustn't be complex (contain aggregates columns and joins) and must return at least one primary key or unique column, or an exception will be thrown.
[12] When the RowUpdating events fire on the SqlCeDataAdapter object.
NOTE
If the SELECT statement changes or the connection or transaction associated with the command changes, a developer can call the RefreshSchema method of the SqlCeCommandBuilder to regenerate the insert, update, and delete commands.
Just like SqlClient, the SqlServerCe provider supports transactions, or the ability to group a series of data modifications in an atomic operation. This is useful if an application needs to update two tables, with the requirement that if one of the updates fails, they both fail (a parent/child relationship, for example).
This is accomplished through the BeginTransaction method of the SqlCeConnection object, which spawns a SqlCeTransaction object used to control the outcome (Commit or Rollback) of the transaction. For example, the following code snippet uses the GetBattingCmd method shown in Listing 5-8 and the GetPitchingCmd method (not shown) to execute two commands in a single transaction:
SqlCeTransaction trans = null; Try { trans = cnCE.BeginTransaction(); SqlCeCommand bat = SqlCeUtils.GetBattingCmd(cnCE, trans); SqlCeCommand pitch = SqlCeUtils.GetPitchingCmd(cnCE, trans); // populate the commands with the new values bat.ExecuteNonQuery(); pitch.ExecuteNonQuery(); trans.Commit(); } catch (SqlCeException e) { if (trans != null) {trans.Rollback();} LogSqlError("MyMethod",e); // most likely throw a custom exception }
You'll notice here that the GetBattingCmd and GetPitchingCmd methods accept a transaction as the second argument. Referring to Listing 5-8, this transaction, if instantiated, is associated with the command object using its Transaction property.
However, the transactional behavior of SQLCE differs from SQL Server 2000, and so developers must be aware of four differences. First, SQLCE only supports an isolation level of ReadCommitted, which exclusively locks data being modified in a transaction. As a result, the IsolationLevel property of SqlCeTransaction can only be set to the ReadCommitted value of the IsolationLevel enumeration. Second, SQLCE supports nested transactions, but only up to five levels. Third, SQLCE holds an exclusive lock on any table that has been modified in a transaction.[13] This means that any attempt to access any data from the table outside the transaction, while it is pending, will result in an exception. Fourth, if a data reader is opened within a transaction, the data reader will automatically be closed if the transaction is rolled back. If the transaction commits, the data reader can still be used.
[13] As opposed to row- and page-level locks used by SQL Server 2000.
As discussed in Chapter 4, applications written with VS .NET 2003 and the Compact Framework can access a SQL Server 2000 server remotely using the SqlClient .NET Data Provider. And, as discussed in this chapter, applications can store data locally in SQLCE using the SqlServerCe .NET Data Provider. In some scenarios, an application may wish to do both, for example, by accessing the remote SQL Server when connected to a corporate LAN via a direct connection, WLAN, or WAN and accessing SQLCE when disconnected.
In these instances, developers can take advantage of the object-oriented nature of the Compact Framework to write code that can be used with either provider. Doing so allows a greater level of code reuse and easier porting of code from the desktop Framework to the Compact Framework. Abstracting data providers is possible since, as mentioned in Chapter 4, all .NET Data Providers are implemented using the same underlying base classes and interfaces. These include the interfaces IDbConnection, IDbCommand, IDataRecord, IDataParameter, IDbDataParameter, IDataParameterCollection, IDataReader, IDataAdapter, IDbDataAdapter, and IDbTransaction, along with the DataAdapter and DbDataAdapter classes, among others, found in the System.Data and System.Data.Common namespaces.
Interfaces or Base Classes?The Compact Framework relies on both interfaces and base classes to allow code reuse through inheritance and polymorphism. Simply put, interfaces (typically prefixed with an "I") enable interface inheritance by allowing a class to implement a set of method signatures defined in the interface. When using interface inheritance, the class implementing the interface must include all of the method signatures from the interface but must implement the functionality of the methods itself. Using a base class, a class may use implementation inheritance to inherit both the method signatures and the implementation (the code) in the base class. The derived class may then override the methods of the base class to augment or replace the base class code. Both techniques are useful, and, as you would imagine, interface inheritance is used when a variety of different classes needs to implement the same behavior (methods) in different ways, while implementation inheritance is used when classes form a natural hierarchy represented with an "is a" relationship (Employee is a Person). Both can be used together in the same class, although in the Compact Framework, implementation inheritance is restricted to a single inheritance, meaning that each class may inherit only from one base class. Using both techniques, developers can write polymorphic (literally "multiform") code by targeting the reference variables in their code at the interfaces and base classes, rather than at the class inheriting from the interface or base class (often called the concrete class). In this way, at runtime the reference variables may actually refer to instances of any of the concrete classes in the inheritance relationship, thereby allowing the code to work in a variety of scenarios. |
One technique for abstracting the data provider used is to implement the Abstract Factory design pattern documented in the book Design Patterns, as noted in the "Related Reading" section at the end of the chapter. This design pattern allows code to create families of related classes without specifying their concrete classes at design time. In this case, the family of related classes comprises the classes that make up a data provider, including connection, command, data adapter, and parameter.
Although it is possible to use the Abstract Factory pattern as documented in Design Patterns, a slight variant of the pattern, shown in Listing 5-9, is flexible because it allows the data provider to be specified in a shared method of the Abstract Factory class rather than having to be hard-coded at the creation of the class at runtime.
Public Enum ProviderType SqlClient = 0 SqlServerCe = 1 End Enum Public MustInherit Class ProviderFactory Public Shared Function CreateFactory( _ ByVal provider As ProviderType) As ProviderFactory If provider = ProviderType.SqlClient Then Return New SqlClientFactory Else Return New SqlServerCeFactory End If End Function Public MustOverride Function CreateConnection( _ ByVal connect As String) As IDbConnection Public MustOverride Overloads Function CreateDataAdapter( _ ByVal cmdText As String, _ ByVal connection As IDbConnection) As IDataAdapter Public MustOverride Overloads Function CreateDataAdapter( _ ByVal command As IDbCommand) As IDataAdapter Public MustOverride Overloads Function CreateParameter( _ ByVal paramName As String, _ ByVal paramType As DbType) As IDataParameter Public MustOverride Overloads Function CreateParameter( _ ByVal paramName As String, _ ByVal paramType As DbType, _ ByVal value As Object) As IDataParameter Public MustOverride Function CreateCommand( _ ByVal cmdText As String, _ ByVal connection As IDbConnection) As IDbCommand End Class Public NotInheritable Class SqlServerCeFactory Inherits ProviderFactory Public Overrides Function CreateConnection( _ ByVal connect As String) As IDbConnection Return New SqlCeConnection(connect) End Function Public Overloads Overrides Function CreateDataAdapter( _ ByVal cmdText As String, _ ByVal connection As IDbConnection) As IDataAdapter Return New SqlCeDataAdapter(cmdText, _ CType(connection, SqlCeConnection)) End Function Public Overloads Overrides Function CreateDataAdapter( _ ByVal command As IDbCommand) As IDataAdapter Return New SqlCeDataAdapter(CType(command, SqlCeCommand)) End Function Public Overloads Overrides Function CreateParameter( _ ByVal paramName As String, _ ByVal paramType As DbType) As IDataParameter Return New SqlCeParameter(paramName, paramType) End Function Public Overloads Overrides Function CreateParameter( _ ByVal paramName As String, _ ByVal paramType As DbType, _ ByVal value As Object) As IDataParameter Dim parm As New SqlCeParameter(paramName, paramType) parm.Value = value Return parm End Function Public Overrides Function CreateCommand(ByVal cmdText As String, _ ByVal connection As IDbConnection) As IDbCommand Return New SqlCeCommand(cmdText, _ CType(connection, SqlCeConnection)) End Function End Class
As you'll notice in Listing 5-9, the ProviderType enumeration identifies which factory classes are available. The heart of the listing is the abstract (marked as MustInherit in VB and abstract in C#) ProviderFactory class. This class implements a shared method to create an instance of a concrete ProviderFactory class, along with a set of method signatures marked with the MustOverride keyword. This keyword ensures that the class inheriting from ProviderFactory will override the methods to provide an implementation. The SqlServerCeFactory class inherits from ProviderFactory, overriding the base class methods and returning instances of the appropriate SqlServerCe objects (SqlCeConnection, SqlCeDataAdapter, and so forth). Note that the methods of the ProviderFactory class return references to the interfaces implemented by data providers discussed earlier. This is the key to enabling the writing of polymorphic code. Although not shown in the listing due to space constraints, there would, of course, be a corresponding factory class for the SqlClient provider that also inherits from ProviderFactory.
NOTE
To extend the ProviderFactory to support new providers (for example, one for Sybase SQL Anywhere Studio), a developer need only create a factory class that inherits from ProviderFactory. He or she would also likely want to extend the ProviderType enumeration and the CreateFactory method.
To use the ProviderFactory class, a caller need only instantiate the correct class using the shared method, as follows:
Dim pf As ProviderFactory If CheckForNetworkConn() Then ' Go remote pf = ProviderFactory.CreateFactory(ProviderType.SqlClient) Else ' Go local pf = ProviderFactory.CreateFactory(ProviderType.SqlServerCe) End If
In this snippet the CheckForNetworkConn method shown in Chapter 4 is used first to determine if a network connection is available; if so, it uses SqlClient and if not, SqlServerCe. Of course, the value for the ProviderType enumeration could also easily be read from a configuration file or passed into the method as a variable to allow for flexibility.
Once the concrete ProviderFactory has been created, it can be passed into methods like those shown in the listings in this chapter so that the methods can be used against either provider. For example, the ExecDataReader method shown in Listing 5-4 could then be rewritten as shown in Listing 5-10.
Public Shared Function ExecDataReader(ByVal pf As ProviderFactory, _ ByVal sql As String, ByVal cn As IDbConnection) As IDataReader Try ' Create the command Dim cm As IDbCommand = pf.CreateCommand(sql, cn) If cn.State = ConnectionState.Closed Then cn.Open() End If ' Execute data reader Dim dr As IDataReader dr = cm.ExecuteReader() Return dr Catch e As Exception LogSqlError("ExecDataReader",e) Throw New Exception( _ "Could not execute data reader for :" & sql, e) End Try End Function
Note that because the ExecDataReader method can now be used with either provider, it returns an object that implements the IDataReader interface and accepts an IDbConnection object, rather than the concrete types for SqlServerCe. In addition, the creation of the SqlCeCommand object has been replaced with a call to the CreateCommand method of the ProviderFactory, and the reference to the SqlCeException object in the Catch block has been replaced with the generic Exception object.[14]
[14] An alternative and more dynamic approach to creating an abstract factory class using the runtime type creation methods of the desktop Framework and Compact Framework can be found in Chapter 18 of Teach Yourself ADO.NET in 21 Days.