Nigel Thompson
March 8, 1997
Nigel Thompson is an ex-Microsoftie who now lives near Colorado Springs. You can contact him about this article or indeed any other subject via e-mail: nigel-t@msn.com. His Web site can be found at http://ourworld.compuserve.com/homepages/nigelt/.
Click to open or copy the files in the LogView sample application for this technical article.
If you need to talk to a database and you’re familiar with the Microsoft® Foundation Class Library (MFC), but not with databases or the MFC database classes, then getting started on a real project can require a lot of reading and experimentation. This article provides a simple step-by-step guide to accessing a database via the MFC encapsulation of Data Access Objects (DAO). The sample code that accompanies this article includes a simple Microsoft Access database (.MDB file) and an MFC-based application that interrogates and modifies the database records. The sample application is actually a radio listening log. The sample code was built and tested using Microsoft Visual C++® version 4.2 on a Windows® 95 platform.
I don’t propose to teach you about what a database is or even how it works, but it is helpful to begin with the terminology I’ll be using in this article. A database is simply a repository of information organized as a set of tables. Each table consists of a number of records, and each record has a set of fields that hold the data. The application we’ll be looking at here uses a single database that contains two tables. One table holds radio listening log events and the other table holds data about radio band frequencies.
If you read through the MFC documentation for information about MFC database classes, you will find that there is one set of classes based upon Data Access Objects (DAO) and another set based on Open Database Connectivity (ODBC). The ODBC classes provide access to a wide range of databases and are accessed via ODBC drivers written by the database vendor. The DAO classes provide access to a more limited range of databases directly, but the access is made using the Microsoft Jet database engine, which generally provides much higher performance than the ODBC classes. The Microsoft Jet engine can also access ODBC databases, so DAO is not limited to just those databases supported directly by the engine. DAO is definitely the way to go if you are accessing a database created with Microsoft Access or Visual Basic®, as well as some ISAM (indexed sequential access method) databases.
Since my own simple project was based on a Microsoft Access database, I chose to use the DAO classes in MFC to access it.
Once you have made the choice of which set of interfaces to use (in the case of this sample, DAO), the next step is to figure out exactly which of the many CDao… classes you will need in your application. There is a hierarchy of classes that represent workspaces, databases, records, queries, tables, and more. All you need to get started are a CDaoDatabase object and one or two CDaoRecordset objects. There are a few other things we’ll need along the way, but for now these two classes will get us going.
The CDaoDatabase class represents the database you want to manipulate. Databases are handled similarly to the way we deal with files: they are opened by name, specifying the type of access required—read only, read/write, and so on—and are closed when we have finished with them.
In the simplest case, an application opens the database when it starts up, and it closes the database when it ends.
The CDaoRecordset class is the workhorse of any database application. You can think of a recordset as an array of records taken from the database. In practice, things are a little more sophisticated than that, and in fact the recordset contains minimal data until you attempt to access records or fields of records included in the recordset. The idea is to keep to a minimum the amount of data traffic between the recordset and the actual database so that fields in records within the recordset are populated from data in the database only when needed. A recordset is built from the database by constructing a query. The query retrieves a set of records from the database, and you can then manipulate this set of records via the CDaoRecordset object. The query can be as simple as fetching every record and every field, or it can be more complex, such as asking for records containing only certain types of data. We’ll look more closely at how the queries are constructed later.
Each recordset has the concept of a current position. This is very important, as all operations on record fields are always performed on the current record. However, if the current record is deleted, it then becomes invalid. If the current record pointer still points to the deleted record, that record remains invalid until you move it to another record. Therefore, when we access records we must be careful to ensure that the current record is valid. (Note that if you attempt to perform some illegal operation, the CDaoRecordset class generates an exception.)
MFC provides a CDaoWorkspace object to encapsulate the DAO concept of a workspace. A workspace can contain many databases. If you don’t provide a workspace object, each of your CDaoDatabase objects gets connected to a default workspace object, which works well in the simple cases we’re dealing with here.
Many C and even C++ programmers have grown up in a world where exception handling is not supported by the compiler, and consequently they may never have used it. Quite a few MFC classes generate exceptions when something goes wrong, and all of the database support classes generate exceptions to report errors.
Handling errors by exception trapping is often much more elegant than using a return value to report success or failure. The DAO classes report exceptions through a CDaoException object. The object includes a textual description of the error as reported by the underlying DAO engine, so getting some idea of what has gone wrong is as simple as showing a message box with the error text. Here’s an example of a short piece of code that handles exceptions this way:
CDaoRecordset rs(&m_db);
try {
rs.Open(dbOpenDynaset, szQuery, dbReadOnly);
// Do something useful.
rs.Close();
} catch (CDaoException* e) {
AfxMessageBox(e->m_pErrorInfo->m_strDescription, MB_ICONEXCLAMATION);
}
In the code above, an attempt to make use of a CDaoRecordset object is bracketed between try and catch statements. If any error occurs in the code between try and catch, the code after the catch statement is executed. In this case, a message box shows the error text.
This is a little oversimplified, as it’s actually possible to have different types of exceptions thrown. For example, an attempt to access an invalid memory location might throw a CMemoryException. However, in order to keep my code simple, my sample only handles database errors and ignores other types of exceptions, such as memory exceptions.
In order to access the actual data contained in the fields of a record in a recordset, we must use methods in the CDaoRecordset class either to read or to write individual fields. Data is usually transferred to and from record fields using a COIeVariant object, which is very handy, since it can be used to hold anything from an int to a text string. It can, however, be a little annoying to use in some cases, as we’ll see. In particular, when using strings of text you need to be very careful that you don’t inadvertently send a regular ANSI string to a function that expects a Unicode string. The COleVariant object is designed for international use and consequently makes use of Unicode strings in some cases.
You’ve probably had enough introduction by now and want to see some real code, so let's look at how the LogView sample application works. This application manipulates records from a database used to track shortwave radio stations. Each time a new station is heard, an entry noting the frequency, transmission mode, and station name is added to the database, along with some descriptive text. The log viewer application allows you to enter a sample frequency and see a list of previously heard stations near the selected frequency. The radio spectrum is divided up by international and local agreements into various bands. Data describing these bands is held in a separate table in the database. When the log viewer application has found a set of records near a requested frequency, it also looks up which band the frequency occurs in and displays this information in a separate window. Figure 1 shows a screen shot of the application.
Figure 1. The LogView application
In a proposed final version of this application, the radio will automatically update the frequency edit control and cause the application to look up data from the database, thus tracking the radio. But for now, the user enters the frequency manually into the edit control and then clicks Lookup to initiate a new query of the database.
The LogView application was built using the Visual C++ AppWizard to create a dialog-type application. I specifically avoided using any kind of document/view architecture and especially avoided using the database view classes because I wanted to have total control over my access to the database. I also wanted to learn more about how it all works, rather than just accepting a solution that might or might not have done what I needed.
The application consists of LogView.cpp, which is the main application startup code as built by AppWizard; LogViewDlg.cpp, which is the code that controls the dialog window shown in Figure 1 and is where all the real action takes place; and EntryDlg.cpp, which is code for a dialog box we’ll see later that allows the entry of new database records.
The application accesses just one database, so we need a single CDaoDatabase object. I decided to use a single CDaoRecordset object for all the manipulation of the log entry records and, as we’ll see later, another CDaoRecordset object to look up the data for the band information window that is stored in a separate table in the database.
Here’s part of the LogViewDlg.h header file, showing the definition of the database and recordset objects we’ll be using:
class CLogViewDlg : public CDialog
{
[. . .]
protected:
[. . .]
CDaoDatabase m_db;
CDaoRecordset m_rsLogs;
};
The MFC DAO classes use a two-stage construction process (just as many other MFC classes do), so the actual C++ constructor does very little. Most of the work is done later using an Open member function or something similar. There is, however, a small detail that we need to take care of when the CDaoRecordset object is constructed, and that is to connect it to the CDaoDatabase object. It’s usual to associate a CDaoRecordset object with a CDaoDatabase object after the database has been opened. It’s also possible, though, to make the association before the database is open. That’s what we need to do here, because we can’t both declare the database object and open it at the same time. So the constructor for the dialog box needs to ensure that the constructor for the CDaoRecordset object gets passed a pointer to the CDaoDatabase object. Here’s the code:
CLogViewDlg::CLogViewDlg(CWnd* pParent /*=NULL*/)
: CDialog(CLogViewDlg::IDD, pParent), m_rsLogs(&m_db) // *** Important
{
//{{AFX_DATA_INIT(CLogViewDlg)
m_dFreq = 0.0;
//}}AFX_DATA_INIT
// Note that LoadIcon does not require a subsequent DestroyIcon in Win32.
m_hIcon = AfxGetApp()->LoadIcon(IDR_MAINFRAME);
}
Most of this was generated by AppWizard and the Class Wizard. The only addition is the statement:
", m_rsLogs(&m_db)"
Now that the database and recordset objects have been constructed, we can go on to open the database. In the sample application, this happens when the dialog box is first initialized in the CLogViewDlg::OnInitDialog function. This function is generated by AppWizard, and all we need to do is add a few lines to the end of the function to open the database:
BOOL CLogViewDlg::OnInitDialog()
{
[. . .]
// Open the database.
try {
m_db.Open("\\\\kirk\\nigelt\\database\\rxlog.mdb", FALSE, FALSE);
} catch (CDaoException* e) {
AfxMessageBox(e->m_pErrorInfo->m_strDescription, MB_ICONEXCLAMATION);
}
// Set the initial frequency.
m_dFreq = 14.0;
UpdateData(FALSE);
OnLookup();
return TRUE; // Return TRUE unless you set the focus to a control.
}
Notice that the attempt to open the database is between a try/catch pair. In the example shown here, I’ve hard-coded the path to the database on one of my servers. The two FALSE arguments specify that the database is to be opened for non-exclusive use and not in read-only mode. In other words, we'll allow another user to access it at the same time, and we want to be able to modify it.
A handler for the WM_DESTROY message is added to ensure that the database is closed when the application exits:
void CLogViewDlg::OnDestroy()
{
CDialog::OnDestroy();
// Close the logs recordset.
if (m_rsLogs.IsOpen()) {
m_rsLogs.Close();
}
// Close the database.
if (m_db.IsOpen()) {
m_db.Close();
}
}
Note that the recordset (which we haven’t made use of yet) also gets closed when the application terminates.
Just as a slight aside here, I’m always a bit irritated that the AppWizard-generated dialog-box application includes a Cancel button that has the ESC key as an accelerator. The side effect of this is that even if you delete the Cancel button from the dialog box, pressing the ESC key still terminates the application. So, to stop the ESC key from ending the application, while still retaining the ability to close the application from its system menu box, I add a handler for the Cancel button and also for the WM_CLOSE message:
// These two functions prevent the ESC key from ending the dialog
// but still allow it to be closed from the system menu.
void CLogViewDlg::OnCancel()
{
// Just ignore this.
}
void CLogViewDlg::OnClose()
{
// End the dialog.
CDialog::EndDialog(0);
}
Obviously, it’s a lot easier to add the handler for the Cancel button before you remove it from the initial AppWizard-generated dialog box!
Just so that the application looks busy when it starts, I like to have it do a lookup and populate the list boxes with some default data. So, at the end of the InitDialog function, I set a default value for the lookup frequency and effectively press the Lookup button by calling the button’s OnLookup function directly:
BOOL CLogViewDlg::OnInitDialog()
{
[. . .]
// Set the initial frequency.
m_dFreq = 14.0;
UpdateData(FALSE);
OnLookup();
return TRUE; // Return TRUE unless you set the focus to a control.
}
The UpdateData call copies the member data to the edit control on the dialog box. The m_dFreq member variable was added using the Class Wizard and is of type double. Now let’s look at how the data is obtained from the database and shown in the list box. Here’s the entire function:
void CLogViewDlg::OnLookup()
{
// Ensure the database is open.
if (!m_db.IsOpen()) {
return;
}
// Get the frequency (m_dFreq).
UpdateData(TRUE);
// Reset the list boxes.
m_lbLogs.ResetContent();
m_lbBand.ResetContent();
// Construct a query to find records in the database
// near to the frequency of interest.
if ((m_dFreq < 0.5) || (m_dFreq > 1000.0)) {
return;
}
double dMin = m_dFreq * 0.9;
double dMax = m_dFreq * 1.1;
char szQuery[256];
sprintf(szQuery,
"SELECT * FROM Frequencies WHERE Frequency BETWEEN %f AND %f",
dMin, dMax);
double dCloseDiff = 999999;
int iClosest = 0;
try {
if (m_rsLogs.IsOpen()) m_rsLogs.Close();
m_rsLogs.Open(dbOpenDynaset, szQuery);
int iRecords = m_rsLogs.GetRecordCount();
if (!m_rsLogs.IsEOF()) m_rsLogs.MoveFirst();
while (!m_rsLogs.IsEOF()) {
COleVariant vFreq = m_rsLogs.GetFieldValue("Frequency");
COleVariant vMode = m_rsLogs.GetFieldValue("Mode");
COleVariant vStation = m_rsLogs.GetFieldValue("Station");
COleVariant vDesc = m_rsLogs.GetFieldValue("Description");
COleVariant vDate = m_rsLogs.GetFieldValue("Date");
COleVariant vTimes = m_rsLogs.GetFieldValue("Times");
char buf[256];
sprintf(buf, "%9.4f %-3s %s, %s",
vFreq.dblVal,
vMode.bstrVal,
vStation.bstrVal,
vDesc.bstrVal);
if (vTimes.vt != VT_NULL) {
strcat(buf, " (");
strcat(buf, (const char*) vTimes.bstrVal);
strcat(buf, ")");
}
int iSel = m_lbLogs.AddString(buf);
// See if this is the closest.
double dDiff = fabs(vFreq.dblVal - m_dFreq);
if (dDiff < dCloseDiff) {
dCloseDiff = dDiff;
iClosest = iSel;
}
m_rsLogs.MoveNext();
}
} catch (CDaoException* e) {
// Barf.
AfxMessageBox(e->m_pErrorInfo->m_strDescription, MB_ICONEXCLAMATION);
}
// Select the closest item.
m_lbLogs.SetCurSel(iClosest);
UpdateBandInfo();
}
The first step is to get the requested frequency from the edit control and reset the contents of both the log data and band information list boxes. The next step is to construct a Microsoft SQL Server query that defines the data we want to extract from the database. I want to locate all records within +/- 10 percent of the chosen frequency, so I compute what the 90 percent and 110 percent frequencies are and then construct a SQL statement in a character buffer. If we entered 14.0 as the frequency, the query text would look like this:
SELECT * FROM Frequencies WHERE Frequency BETWEEN 12.6 AND 15.4
This statement says that we want to extract (select) all (*) the fields of the records in the “Frequencies” table of the database where the “Frequency” field value of each record lies between 12.6 and 15.4. The Structured Query Language (SQL) used to create these queries is explained in numerous books. I happen to use The Practical SQL Handbook: Using Structured Query Language by Bowman, Emerson, and Darnovsky (Addison-Wesley, 1993, ISBN: 0-201-62623-3). And that’s all I’m going to say about SQL.
Having constructed the query, we ensure that the recordset object is currently closed and then we open it, passing the query string and a flag requesting that the results be returned in a dynaset-type object. Recordsets can exist in three forms: table sets, dynasets, and snapshots. The dynaset object is most useful because it allows us to modify it and write the results back to the database. The object is also "live" in the sense that if we ask it to perform the same query again (via the Requery member function), it will return the current state of the database, including any modifications made by other users. So far as I can tell, the dynaset is the tool of choice when building recordsets.
When the query returns results, the recordset is populated with any records that were found to match the request. The final job is to walk through the recordset copying the data from each record to the list box so we can see it. To begin this process, we first move the current record to the start of the recordset using the MoveFirst member function. Once the current record has been processed, the MoveNext function is used to advance the current record pointer until we encounter the end of the recordset.
Extracting the data from the records is a little tricky, and, frankly, I think that something more useful than a COleVariant object is called for. However, for this application I decided to stick strictly to the tools available and not to create any new ones of my own.
The value of each field is read using the GetFieldValue member function, which returns the data in a COleVariant object. The hard part is extracting the data from the COleVariant object. Because I created the original database, I know the type of data (string, double, bool, and so on) stored in each field. Given that knowledge, it’s not too difficult to find the right member of the large union inside the COleVariant object that is holding the data we need. For example, the frequency field is stored in the database as a double, so we can extract it from the COleVariant object via the object’s dblVal member variable and strings can be extracted via the bstrVal member.
If you don’t know the type of data in the COleVariant object, you can examine the vt member variable, which has a value indicating the type (for example, VT_BSTR). You can then decide how to extract the data from the COleVariant object. As you see from the sample code above, I cheated a little bit and only extracted the fields that were easy!
Once the data from all the fields has been formatted into a character buffer, that string is added to the list box.
The remaining code is used to find the record with a frequency closest to the one used to initiate the search. This item is selected in the list box after all the recordset's records have been processed.
When the log entries have been completed, a second search is done in the table that holds band information, and the band information list box is populated with the results. The code to do this is very similar to that used to fill the log list, but I’ll include it here for completeness:
void CLogViewDlg::UpdateBandInfo()
{
// Erase the current data.
m_lbBand.ResetContent();
// Get the selected frequency.
double dFreq = 0;
int iFSel = m_lbLogs.GetCurSel();
if (iFSel == LB_ERR) {
dFreq = m_dFreq;
} else {
// Extract the frequency.
char buf[256];
m_lbLogs.GetText(iFSel, buf);
int i = sscanf(buf, "%lf", &dFreq);
if (i != 1) dFreq = m_dFreq;
}
// Now do the band info.
CDaoRecordset rs(&m_db);
char szQuery[256];
sprintf(szQuery,
"SELECT * FROM Bands WHERE (%f >= Lower AND %f <= Upper)"
"OR (%f >= Lower AND %f <= Upper)",
dFreq, dFreq,
m_dFreq, m_dFreq);
try {
rs.Open(dbOpenDynaset, szQuery, dbReadOnly);
int iRecords = rs.GetRecordCount();
if (iRecords == 0) return;
rs.MoveFirst();
while (!rs.IsEOF()) {
COleVariant vLower = rs.GetFieldValue("Lower");
COleVariant vUpper = rs.GetFieldValue("Upper");
COleVariant vName = rs.GetFieldValue("Name");
COleVariant vDesc = rs.GetFieldValue("Description");
char buf[256];
sprintf(buf, "%6.4f - %6.4f %s, %s",
vLower.dblVal,
vUpper.dblVal,
vName.bstrVal,
vDesc.bstrVal);
m_lbBand.AddString(buf);
rs.MoveNext();
}
rs.Close();
} catch (CDaoException* e) {
AfxMessageBox(e->m_pErrorInfo->m_strDescription, MB_ICONEXCLAMATION);
}
}
Making a new entry to a database is quite simple, provided you know the rules of the game. I spent quite a few hours getting this little piece of code to work and in the end it all came down to just a few key pieces of knowledge cunningly omitted from the reference documents.
To make a new entry, the data for the record is first obtained from the user via the dialog box shown in Figure 2. I’m not going to show the dialog box code, because it’s trivial. Data from the dialog box is then used to construct a new record for the database.
Figure 2. The New Entry dialog box
Here’s the code that shows the dialog box, extracts the data, and creates the new record:
void CLogViewDlg::OnNewEntry()
{
// Get the frequency (m_dFreq).
UpdateData(TRUE);
// Show the dialog to get the new info.
CNewEntryDlg dlg;
dlg.m_dFreq = m_dFreq;
if (dlg.DoModal() != IDOK) return;
// Write a new entry to the database.
try {
// Add a new entry to the recordset.
m_rsLogs.AddNew();
// Position the recordset at the new entry.
m_rsLogs.SetBookmark(m_rsLogs.GetLastModifiedBookmark());
// Set the new field values.
char buf[64];
COleVariant v;
sprintf(buf, "%f", dlg.m_dFreq);
v.SetString(buf, VT_BSTRT);
m_rsLogs.SetFieldValue("Frequency", v);
v.SetString(dlg.m_strMode, VT_BSTRT);
m_rsLogs.SetFieldValue("Mode", v);
v.SetString(dlg.m_strStation, VT_BSTRT);
m_rsLogs.SetFieldValue("Station", v);
v.SetString(dlg.m_strDesc, VT_BSTRT);
m_rsLogs.SetFieldValue("Description", v);
m_rsLogs.SetFieldValue("Date", dlg.m_Date);
v.SetString(dlg.m_strTime, VT_BSTRT);
m_rsLogs.SetFieldValue("Times", v);
// Update the database.
m_rsLogs.Update();
// Rebuild the view.
OnLookup();
} catch (CDaoException* e) {
AfxMessageBox(e->m_pErrorInfo->m_strDescription, MB_ICONEXCLAMATION);
}
}
The most important steps are to call AddNew to create the new record and then to move the current record to the new record just added to the recordset. This is done with the statement:
m_rsLogs.SetBookmark(m_rsLogs.GetLastModifiedBookmark());
This code makes use of the recordset bookmark feature. Each time a modification is made to the recordset, the "last modified" bookmark value is set to point to that record. The AddNew function sets the last-modified bookmark, so moving to the new record only requires moving to that bookmark position.
As a point of interest, new records are always added to the end of a dynaset, but my attempts to use the MoveLast function to move the current record pointer to the new record never worked.
Setting the field values of the new record is a little tedious. Essentially, most of them are written from character strings by first creating a COleVariant object from the string and then calling SetFieldValue, passing the COleVariant object as an argument. When you set the string value, it is most important to use the VT_BSTRT type specifier, which indicates an ANSI string. Writing a date field requires the use of a COleDateTime object, which the New Entry dialog box creates.
Once all the field values have been set, a call to Update commits the changes made in the recordset to the database. The final piece of code causes a new lookup to be performed to reflect the changes in the list box. This is actually somewhat inefficient, because the new data is already in the recordset, so you really only need to repopulate the list box from the recordset. It isn’t really necessary to perform a new query. I just found this easy to do.
Deleting records from the recordset, and hence from the database, is quite simple. You position the current record pointer on the record to be deleted and call the Delete function. Setting the current record pointer is the tricky part.
Since my list boxes are not owner-drawn, I don’t have a way to put useful information such as the database-record index number into each line. So, when you select an item in the list box for deletion, there has to be a way to locate the appropriate record in the recordset.
We could do a search in the recordset for a record matching the data in the list box, but that's a lot more complex than the simple solution I finally came up with.
I reasoned that the list box and recordset are exactly in sync, so the index number of the selected record in the list box is also the index into the recordset. Because I’m occasionally a little paranoid and also because I don’t really like one-hit delete buttons, I chose to show the record to the user and get confirmation before deleting it. Here’s the entire chunk of code that is called when the Delete button is clicked:
void CLogViewDlg::OnDelete()
{
ASSERT(m_rsLogs.GetRecordCount() > 0);
// Get the current selection.
int iSel = m_lbLogs.GetCurSel();
if (iSel == LB_ERR) return; // no selection
// Make this the current record in the recordset.
// Note: for this to work it's important that the list box and
// recordset are in sync since we rely on the relative position in
// the list box to give us the relative position in the recordset.
m_rsLogs.MoveFirst();
while (iSel--) {
m_rsLogs.MoveNext();
}
// Verify with the user that this is the record to delete.
COleVariant vFreq = m_rsLogs.GetFieldValue("Frequency");
COleVariant vMode = m_rsLogs.GetFieldValue("Mode");
COleVariant vStation = m_rsLogs.GetFieldValue("Station");
COleVariant vDesc = m_rsLogs.GetFieldValue("Description");
char buf[256];
sprintf(buf,
"Delete: %9.4f %-3s %s, %s ?",
vFreq.dblVal,
vMode.bstrVal,
vStation.bstrVal,
vDesc.bstrVal);
if (AfxMessageBox(buf, MB_YESNO) != IDYES) return;
// Delete the record from the recordset.
m_rsLogs.Delete();
// Regenerate the list box view of the data.
OnLookup();
}
It is important to note that once you have deleted the current record from the recordset, it has also been deleted from the database. Also note that the current record pointer is still pointing at the deleted record, but any attempt to access it will result in an exception. To me, this is just plain silly. The current record pointer should always point to a valid record unless the recordset is empty. I guess nobody could decide whether to move it after or before the deleted record.
To avoid any problems, and also to reflect the new state of the database, I re-run the query and repopulate the list box with the new data.
If you don't want to go to the trouble of reading and writing all the field data yourself, you can use the Class Wizard to derive your own recordset class from CDaoRecordset. The Class Wizard will ask you for the name of the database and which tables you want to include. It then builds a set of member variables into the derived class and includes a mechanism called "Field Exchange" to allow you to read and write an entire record in one go. The mechanism works very much like the UpdateData method used in dialog boxes to exchange data between member variables and dialog box elements.
I avoided using the Field Exchange mechanism because I wanted to learn more about the fundamentals. I also felt that it created yet another layer between my application and the database, and that might have made it difficult to do things exactly the way I wanted. The Field Exchange support in the Class Wizard does, however, make it very easy to work with recordsets, so it is worth looking at at least once.
To get going with database access, you only need the CDaoDatabase and CDaoRecordset classes. You also need the COleVariant class to read and write record fields. Once you’ve mastered these classes, you might find it worth investigating some of the other support classes, such as CDaoTableDef and CDaoQueryDef, particularly if you want to create an entire database from scratch in an MFC application. If your work only involves manipulating existing databases, then perhaps you can ignore these other classes and live a simpler life. I do.