Sie sind auf Seite 1von 321

CONTENT

Useful Web Resources ............................................................................................................................ 5


What are Database objects? ................................................................................................................... 6
The Query Lost My Records! .................................................................... Error! Bookmark not defined.
Common Errors with Null ........................................................................ Error! Bookmark not defined.
Calculated Fields ..................................................................................................................................... 8
Relationships between Tables .............................................................................................................. 10
Validation Rules .................................................................................................................................... 12
When to use validation rules ................................................................................................................ 14
Don't use Yes/No fields to store preferences ....................................................................................... 15
Using a Combo Box to Find Records ..................................................................................................... 18
Referring to Controls on a Subform ...................................................................................................... 24
Enter value as a percent ....................................................................................................................... 25
Assigning Auto Keys for Aligning Controls ............................................... Error! Bookmark not defined.
Why does my form go completely blank? ............................................... Error! Bookmark not defined.
Scroll records with the mouse wheel.................................................................................................... 27
Avoid #Error in form/report with no records ....................................................................................... 30
Limiting a Report to a Date Range ........................................................................................................ 32
Print the record in the form .................................................................................................................. 36
Bring the total from a subreport back onto the main report ............................................................... 38
Numbering Entries in a Report or Form................................................................................................ 40
Getting a value from a table: DLookup()............................................................................................... 45
Locking bound controls ......................................................................................................................... 47
Nulls: Do I need them?.......................................................................................................................... 52
Common Errors with Null ..................................................................................................................... 54
Problem properties ............................................................................................................................... 58
Default forms, reports and databases .................................................................................................. 63
Calculating elapsed time ....................................................................................................................... 67
A More Complete DateDiff Function .................................................................................................... 68
Constructing Modern Time Elapsed Strings in Access .......................................................................... 77
Quotation marks within quotes ............................................................................................................ 85
Why can't I append some records? ...................................................................................................... 87

Rounding in Access ............................................................................................................................... 89


Assign default values from the last record ........................................................................................... 96
Managing Multiple Instances of a Form ............................................................................................. 104
Rolling dates by pressing "+" or "-" ..................................................................................................... 108
Return to the same record next time form is opened ........................................................................ 109
Unbound text box: limiting entry length ............................................................................................ 112
Properties at Runtime: Forms ............................................................................................................. 116
Highlight the required fields, or the control that has focus ............................................................... 118
Combos with Tens of Thousands of Records ...................................................................................... 121
Adding values to lookup tables ........................................................................................................... 125
Use a multi-select list box to filter a report ........................................................................................ 132
Print a Quantity of a Label .................................................................................................................. 136
Has the record been printed? ............................................................................................................. 138
Code accompanying article: Has the record been printed?...................................................... 139
Cascade to Null Relations.................................................................................................................... 144
List Box of Available Reports ............................................................................................................... 150
Format check boxes in reports ........................................................................................................... 154
Sorting report records at runtime....................................................................................................... 156
Reports: Page Totals ........................................................................................................................... 159
Reports: a blank line every fifth record .............................................................................................. 160
Reports: Snaking Column Headers...................................................................................................... 162
Duplex reports: start groups on an odd page ..................................................................................... 164
Lookup a value in a range ................................................................................................................... 166
Action queries: suppressing dialogs, while knowing results............................................................... 169
Truncation of Memo fields ................................................................................................................. 171
Crosstab query techniques ................................................................................................................. 173
Subquery basics .................................................................................................................................. 177
Ranking or numbering records ........................................................................................................... 183
Common query hurdles ...................................................................................................................... 187
Reconnect Attached tables on Start-up .............................................................................................. 192
Self Joins.............................................................................................................................................. 194
Field type reference - names and values for DDL, DAO, and ADOX ................................................... 196
Set AutoNumbers to start from ... ...................................................................................................... 198
Custom Database Properties .............................................................................................................. 201

Error Handling in VBA ......................................................................................................................... 206


Extended DLookup()............................................................................................................................ 210
Extended DCount() .............................................................................................................................. 215
Extended DAvg() ................................................................................................................................. 219
Archive: Move Records to Another Table ........................................................................................... 223
List files recursively ............................................................................................................................. 226
Enabling/Disabling controls, based on User Security ......................................................................... 231
Concatenate values from related records .......................................................................................... 236
MinOfList() and MaxOfList() functions................................................................................................ 241
Age() Function ..................................................................................................................................... 244
TableInfo() function ............................................................................................................................ 252
DirListBox() function ........................................................................................................................... 256
PlaySound() function ........................................................................................................................... 258
ParseWord() function.......................................................................................................................... 260
FileExists() and FolderExists() functions .............................................................................................. 266
ClearList() and SelectAll() functions .................................................................................................... 269
Count lines (VBA code) ....................................................................................................................... 272
Insert characters at the cursor ............................................................................................................ 278
Hyperlinks: warnings, special characters, errors ................................................................................ 283
Intelligent handling of dates at the start of a calendar year .............................................................. 291
Splash screen with version information ............................................................................................. 300
Printer Selection Utility ....................................................................................................................... 307

USEFUL WEB RESOURCES


www.allenbrowne.com

WHAT ARE DATABASE OBJECTS?


When you create a database, Access offers you Tables, Queries, Forms, Reports,
Macros, and Modules. Here's a quick overview of what these are and when to use them.
Tables
All data is stored in tables. When you create a new table, Access asks you define fields (column
headings), giving each a unique name, and telling Access the data type.
You can use the "Text" type for most data, including numbers that don't need to be added e.g.
phone numbers or postal codes.
Once you have defined a table's structure, you can enter data. Each new row that you add to the
table is called a record.

Queries
Use a query to find or operate on the data in your tables. With a query, you can display the records
that match certain criteria (e.g. all the members called "Barry"), sort the data as you please (e.g. by
Surname), and even combine data from different tables.
You can edit the data displayed in a query (in most cases), and the data in the underlying table will
change.
Special queries can also be defined to make wholesale changes to your data, e.g. delete all members
whose subscriptions are 2 years overdue, or set a "State" field to "WA" wherever postcode begins
with 6.

Forms
These are screens for displaying data from and inputting data into your tables. The basic form has
an appearance similar to an index card: it shows only one record at a time, with a different field on
each line. If you want to control how the records are sorted, define a query first, and then create a
form based on the query. If you have defined a one-to-many relationship between two tables, use
the "Subform" Wizard to create a form which contains another form. The subform will then display
only the records matching the one on the main form.

Reports
If forms are for input, then reports are for output. Anything you plan to print deserves a report,
whether it is a list of names and addresses, a financial summary for a period, or a set of mailing
labels.

Macros
An Access Macro is a script for doing a job. For example, to create a button which opens a report,
you could use a macro which fires off the "OpenReport" action. Macros can also be used to set one
field based on the value of another (the "SetValue" action), to validate that certain conditions are
met before a record saved (the "CancelEvent" action) etc.

Modules
This is where you write your own functions and programs if you want to. Everything that can be
done in a macro can also be done in a module.
Modules are far more powerful, and are essential if you plan to write code for a multiuser environment.

CALCULATED FIELDS
How do you get Access to store the result of a calculation? For example, if you have fields
named Quantity and UnitPrice, how do you get Access to write Quantity * UnitPrice to another field
called Amount?

CALCULATIONS IN QUERIES
Calculated columns are part of life on a spreadsheet, but do not belong in a database table. Never
store a value that is dependent on other fields - it's a basic rule of normalization.
So, how do you get the calculated field if you do not store it in a table? Use a query
Create a query based on your table.
Type your expression into the Field row of the query design grid:
Amount: [Quantity] * [UnitPrice]
This creates a field named Amount. Any form or report based on this query treats the calculated
field like any other, so you can easily sum the results. It is simple, efficient, and fool-proof.

YOU WANT TO STORE A CALCULATED RESULT ANYWAY?


There are circumstances where storing a calculated result makes sense. Say you charge a
construction fee that is normally an additional 10%, but to win some quotes you may want to waive
the fee. The calculated field will not work. In this case it makes perfect sense to have a record where
the fee is 0 instead of 10%, so you must store this as a field in the table.
To achieve this, use the After Update event of the controls on your form to automatically calculate
the fee.
Set the After Update property of the Quantity text box to [Event Procedure].
Click the Build button (...) beside this. Access opens the Code window. Enter this line between
the Private Sub... and End Sub lines:
Private Sub Quantity_AfterUpdate()
Me.Fee = Round(Me.Quantity * Me.UnitPrice * 0.1, 2)
End Sub

Set the After Update property of the UnitPrice text box to [Event Procedure], and click the Build
button. Enter this line.
Private Sub UnitPrice_AfterUpdate()
Call Quantity_AfterUpdate
End Sub

Now whenever the Quantity or UnitPrice changes, Access automatically calculates the new fee, but
the user can override the calculation and enter a different fee when necessary.

WHAT ABOUT CALCULATED FIELDS IN ACCESS 2010?


Access 2010 allows you to put a calculated field into a table, like this:

Just choose Calculated in the data type, and Expression appears below it. Type the expression.
Access will then calculate it each time you enter your record.
This may seem simple, but it creates more problems than it solves. You will quickly find that the
expressions are limited. You will also find it makes your database useless for anyone using older
versions of Access - they will get a message like this:

RELATIONSHIPS BETWEEN TABLES


Database beginners sometimes struggle with what tables are needed, and how to relate one table to
another. It's probably easiest to follow with an example.
As a school teacher, Margaret needs to track each student's name and home details, along with
the subjects they have taken, and the grades achieved. To do all this in a single table, she could try
making fields for:
Name

Address

Home Phone

Subject

Grade

But this structure requires her to enter the student's name and address again for every new subject!
Apart from the time required for entry, can you imagine what happens when a student changes
address and Margaret has to locate and update all the previous entries? She tries a different
structure with only one record for each student. This requires many additional fields - something
like:
Name
Grade for Subject 1

Address
Name of Subject 2

Home Phone
Grade for Subject 2

Name of Subject 1
Name of Subject 3

But how many subjects must she allow for? How much space will this waste? How does she
know which column to look in to find "History 104"? How can she average grades that could be in
any old column? Whenever you see this repetition of fields, the data needs to be broken down
into separate tables.
The solution to her problem involves making three tables: one for students, one for subjects, and
one for grades. The Students table must have a unique code for each student, so the computer
doesn't get confused about two students with the same names. Margaret calls this field StudentID,
so the Students table contains fields:
StudentID - a unique
code for each student.

Suburb

Surname - split
Surname and First
Name to make
searches easier
Postcode

FirstName

Address - split Street


Address, Suburb, and
Postcode for the same
reason

Phone

The Subjects table will have fields:


SubjectID - a unique code for
each subject. (Use the school's
subject code)

Subject - full title of the subject

Notes - comments or a brief


description of what this subject
covers.

The Grades table will then have just three fields:


StudentID - a code that ties this
entry to a student in the
Students table

SubjectID - a code that ties this


entry to a subject in the
Subjects table

Grade - the mark this student


achieved in this subject

After creating the three tables, Margaret needs to create a link between them.
Now she enters all the students in the Students table, with the unique StudentID for each. Next
she enters all the subjects she teaches into the Subjects table, each with a SubjectID. Then at the
end of term when the marks are ready, she can enter them in the Grades table using the
appropriate StudentID from the Students table and SubjectID from the Subjects table.
To help enter marks, she creates a form, using the "Form/Subform" wizard: "Subjects" is the source
for the main form, and "Grades" is the source for the subform. Now with the appropriate subject in
the main form, and adds each StudentID and Grade in the subform.
The grades were entered by subject, but Margaret needs to view them by student. She creates
another form/subform, with the main form reading its data from the Students table, and
the subform from the Grades table. Since she used StudentID when entering grades in her previous
form, Access links this code to the one in the new main form, and automatically displays all the
subjects and grades for the student in the main form.

VALIDATION RULES
Validation rules prevent bad data being saved in your table. You can create a rule for a field (lower
pane of table design), or for the table (in the Properties box in table design.) Use the table's rule to
compare fields.

VALIDATION RULES FOR FIELDS


When you select a field in table design, you see its Validation Rule property in the lower pane.
This rule is applied when you enter data into the field. You cannot tab to the next field until you
enter something that satisfies the rule, or undo your entry.
To do this ...
Accept letters (a - z) only

Validation Rule for Fields


Is Null OR Not Like "*[!a-z]*"

Accept digits (0 - 9) only

Is Null OR Not Like "*[!0-9]*"

Letters and spaces only

Exactly 8 characters

Is Null Or Not Like "*[!a-z OR ""


""]*"
Is Null OR Not Like "*[!((a-z) or (09))]*"
Is Null OR Like "????????"

Exactly 4 digits

Is Null OR Between 1000 And 9999

Positive numbers only

Is Null OR Like "####"


Is Null OR >= 0

No more than 100%

Is Null OR Between -1 And 1

Not a future date


Email address

Is Null OR <= Date()


Is Null OR ((Like "*?@?*.?*")
AND (Not Like "*[ ,;]*"))

You must fill in Field1

Not Null

Digits and letters only

Explanation
Any character outside the range A
to Z is rejected. (Case insensitive.)
Any character outside the range 0
to 9 is rejected. (Decimal point and
negative sign rejected.)
Punctuation and digits rejected.
Accepts A to Z and 0 to 9, but no
punctuation or other characters
The question mark stands for one
character.
For Number fields.
For Text fields.
Remove the "=" if zero is not
allowed either.
100% is 1. Use 0 instead of -1 if
negative percentages are not
allowed.
Requires at least one character, @,
at least one character, dot, at least
one character. Space, comma, and
semicolon are not permitted.
Same as setting the
field's Required property, but lets
you create a custom message (in
the Validation Text property.)

Limit to specific choices

Is Null OR "M" Or "F"

Is Null OR IN (1, 2, 4, 8)
Yes/No/Null field

Is Null OR 0 or -1

It is better to use a lookup table


for the list, but this may be useful
for simple choices such as
Male/Female.
The IN operator may be simpler
than several ORs
The Yes/No field in Access does
not support Null as other
databases do. To simulate a real
Yes/No/Null data type, use a
Number field (size Integer) with
this rule. (Access uses 0 for False,
and -1 for True.)

VALIDATION RULES FOR TABLES


In table design, open the Properties box and you see another Validation Rule. This is the rule for the table.
The rule is applied after all fields have been entered, just before the record is saved. Use this rule to compare
values across different fields, or to delay validation until the last moment before the record is saved.

To do this ...
A booking cannot end before it
starts

Validation Rule for Table


([StartDate] Is Null) OR ([EndDate]
Is Null) OR ([StartDate] <=
[EndDate])

If you fill in Field1, Field2 is


required also

([Field1] Is Null) OR ([Field2] Is Not


Null)

You must enter Field1 or Field2,


but not both

([Field1] Is Null) XOR ([Field2] Is


Null)

Explanation
The rule is satisfied if either field is
left blank;
otherwise StartDate must be
before (or the same as)EndDate.
The rule is satisfied if Field1 is
blank; otherwise it is satisfied only
if Field2 is filled in.
XOR is the exclusive OR.

WHEN TO USE VALIDATION RULES


A database is only as good as the data it contains, so you want to do everything you can to limit bad
data.

FIELD'S VALIDATION RULE


Take a BirthDate field, for example. Should you create a rule to ensure the user doesn't enter a
future date? But did you consider that the computer's date might be wrong? Would it be better to
give a warning rather than block the entry?
The answer to that question is subjective. The question merely illustrates the need to think outside
the box whenever you will block data, not merely to block things just because you cannot imagine a
valid scenario for that data.
Validation Rules are absolute. You cannot bypass them, so you cannot use them for warnings. To
give a warning instead, use an event of your form, such as Form_BeforeUpdate.

ALTERNATIVES TO VALIDATION RULES


Use these alternatives instead of or in combination with validation rules:
Required:
Allow Zero Length:
Indexed:

Lookups:

Input Mask:

Setting a field's Required property to Yes forces the user to enter


something
Setting this property to No for text, memo, and hyperlink fields prevents a
zero-length string being entered
To prevent duplicates in a field, set this property to Yes (No Duplicates).
Using the Indexes box in table design, you can create a multi-field unique
index to the values are unique across a combination of fields
Rather than creating a validation rule consisting of a list of valid values,
consider creating a related table. This is much more flexible and easier to
maintain
Users must enter the entire pattern (without them you can enter some
dates with just 3 keystrokes, e.g. 2/5), and they cannot easily insert a
character if they missed one

DON'T USE YES/NO FIELDS TO STORE PREFERENCES


A common mistake is to create heaps of Yes/No fields in a table to store people's preferences. This
chapter explains how and why you should use a relational design instead.
A sports teacher might set up a matrix to record students' interest in various sports like this:
Student
Josh
Mark
Mary-Anne
Olivier
Trevor

Basketball

Football

Baseball

Tennis

If the teacher knows nothing about databases, he will create a table with a Text field (for the student
name) and a bunch of Yes/No fields so he can tick the sports the student enrols in.
Paper forms are laid out like that, so lots of people make the mistake of building database tables like
that too.

THINKING RELATIONALLY
A major problem with these repeating Yes/No fields is that you must redesign your database every
time you add a new choice. To add Netball, the teacher must create another Yes/No field in the
table. Then he must modify the queries, forms, reports, and any code or macros that handle these
fields. A relational design would avoid this maintenance nightmare.
Thinking relationally, we have two things to consider: students, and sports. One student can be in
many sports. One sport can have many students. Therefore we have a many-to-many relation
between students and sports.

A many-to-many is resolved by using three tables:


Student table (one record per student), with fields:
StudentID

AutoNumber

Surname

Text

FirstName

Text

Sport table (one record per sport), with fields:


SportID

AutoNumber

Sport Text

StudentSport table (one record per preference) with fields:


StudentSportID

AutoNumber

StudentID

Number

Relates to Student.StudentID

SportID

Number

Relates to Sport.SportID

The third table holds the preferences. If Josh is interested in two sports, he has two records in the
StudentSport table.
This relational structure copes with any number of sports, without needing to redesign the tables.
Just add a new record to the Sport table, and the database works without changing all queries,
forms, reports, macros, and code.
You can also create much more powerful queries: there is only one field to examine to find the
sports that match a student (i.e. the SportID field in the StudentSport table.)

CREATING THE USER INTERFACES


Create a form bound to the Student table.
It has a subform bound to the StudentSport table. This Subform has a combo for selecting the sport,
and you add as many rows as you need for that student's sports.

When you add a new sport to the Sport table, it turns up in the combo box automatically. You can
therefore choose it without needing any changes.

USING A COMBO BOX TO FIND RECORDS


It is possible to use an unbound combo box in the header of a form as a means of record navigation.
The idea is to select an entry from the drop-down list, and have Access take you to that record.
Assume you have a table called "tblCustomers" with the following structure:
CustomerID

AutoNumber (indexed as Primary Key).

Company

Text

ContactPerson

Text

A form displays data from this table in Single Form view. Add a combo box to the form's header, with
the following properties:
Name

cboMoveTo

Control Source

[leave this blank]

Row Source Type

Table/Query

Row Source

tblCustomers

Column Count

Column Widths

0.6 in; 1.2 in; 1.2 in

Bound Column

List Width

3.2 in

Limit to List

Yes

Now attach this code to the AfterUpdate property of the Combo Box:

Sub CboMoveTo_AfterUpdate ()
Dim rs As DAO.Recordset

If Not IsNull(Me.cboMoveTo) Then


'Save before move.
If Me.Dirty Then
Me.Dirty = False

End If
'Search in the clone set.
Set rs = Me.RecordsetClone
rs.FindFirst "[CustomerID] = " & Me.cboMoveTo
If rs.NoMatch Then
MsgBox "Not found: filtered?"
Else
'Display the found record in the form.
Me.Bookmark = rs.Bookmark
End If
Set rs = Nothing
End If
End Sub

FILTER A FORM ON A FIELD IN A SUBFORM


The Filter property of forms (introduced in Access 95) makes it easy to filter a form based on a
control in the form. However, the simple filter cannot be used if the field you wish to filter on is not
in the form.
You can achieve the same result by changing the RecordSource of the main form to an SQL
statement with an INNER JOIN to the table containing the field you wish to filter on. If that sounds a
mouthful, it is quite simple to do.
Before trying to filter on a field in the subform, review how filters are normally used within a form.

SIMPLE FILTER EXAMPLE


Take a Products form with a ProductCategory field. With an unbound combo in the form's header,
you could provide a simple interface to filter products from one category.
The combo would have these properties:
Name
ControlSource
RowSource
AfterUpdate

cboShowCat
tblProductCategory

' Leave blank


'Your look up table.

[Event Procedure]

Now when the user selects any category in this combo, its AfterUpdate event procedure filters the
form like this:
Private Sub cboShowCat_AfterUpdate()
If IsNull(Me.cboShowCat) Then
Me.FilterOn = False
Else
Me.Filter = "ProductCatID = """ & Me.cboShowCat & """"
Me.FilterOn = True
End If
End Sub

FILTERING ON A FIELD IN THE SUBFORM


You cannot use this simple approach if the field you wish to filter on is in the subform.
Some products, for example might have several suppliers. You need a subform for the various
suppliers of the product in the main form. The database structure for this example involves three
tables:
tblProduct, with ProductID as primary key.
tblSupplier, with SupplierID as primary key.
tblProductSupplier, a link table with ProductID and SupplierID as foreign keys.
The main form draws its records from tblProduct, and the subform from tblProductSupplier.
When a supplier sends a price update list, how do you filter your main form to only products from
this supplier to facilitate changing all those prices? Remember, SupplierID exists only in the subform.
One solution is to change the RecordSource of your main form, using an INNER JOIN to get the
equivalent of a filter. It is straightforward to create, and the user interface can be identical to the
example above.
Here are the 2 simple steps to filter the main form to a selected supplier:
1. Add a combo to the header of the main form with these properties:
Name
ControlSource
RowSource
AfterUpdate

cboShowSup
'Leave blank
tblSupplier
[Event Procedure]

2. Click the build button (...) beside the AfterUpdate property. Paste this code between
the Sub and End Sub lines:

Dim strSQL As String


If IsNull(Me.cboShowSup) Then
' If the combo is Null, use the whole table as the
RecordSource.
Me.RecordSource = "tblProduct"
Else
strSQL = "SELECT DISTINCTROW tblProduct.* FROM tblProduct "
& _
"INNER JOIN tblProductSupplier ON " & _

"tblProduct.ProductID = tblProductSupplier.ProductID " &


_
"WHERE tblProductSupplier.SupplierID = " & Me.cboShowSup
& ";"
Me.RecordSource = strSQL
End If

Although the SELECT statement does not return any fields from tblProductSupplier, the INNER JOIN
limits the recordset to products that have an entry for the particular supplier, effectively
filtering the products.

COMBINING BOTH TYPES


When you change the RecordSource, Access turns the form's FilterOn property off. This means that
if you use both the Filter and the change of RecordSource together, your code mustsave the filter
state before changing the RecordSource and restore it.
Assume you have provided both the combos described above (cboShowCat and cboShowSup) on
your main form. A user can now filter only products of a certain category and from a particular
supplier. The AfterUpdate event procedure for cboShowSup must save and restore the filter state.
Here is the complete code, with error handling.

Private Sub cboShowSup_AfterUpdate()


On Error GoTo Err_cboShowSup_AfterUpdate
' Purpose: Change the form's RecordSource to only products
from this supplier.
Dim sSQL As String
Dim bWasFilterOn As Boolean

' Save the FilterOn state. (It's lost during RecordSource


change.)
bWasFilterOn = Me.FilterOn

' Change the RecordSource.


If IsNull(Me.cboShowSup) Then

If Me.RecordSource <> "tblProduct" Then


Me.RecordSource = "tblProduct"
End If
Else
sSQL = "SELECT DISTINCTROW tblProduct.* FROM tblProduct
" & _
"INNER JOIN tblProductSupplier ON " & _
"tblProduct.ProductID = tblProductSupplier.ProductID
" & _
"WHERE tblProductSupplier.SupplierID = """ &
Me.cboShowSup & """;"
Me.RecordSource = sSQL
End If

' Apply the filter again, if it was on.


If bWasFilterOn And Not Me.FilterOn Then
Me.FilterOn = True
End If

Exit_cboShowSup_AfterUpdate:
Exit Sub

Err_cboShowSup_AfterUpdate:
MsgBox Err.Number & ": " & Err.Description, vbInformation, &
_
Me.Module.Name & ".cboShowSup_AfterUpdate"
Resume Exit_cboShowSup_AfterUpdate
End Sub

REFERRING TO CONTROLS ON A SUBFORM


Sooner or later, you will need to refer to information in a control on another form - a subform, the
parent form, or some other form altogether. Say for example we have a form called
"Students" that displays student names and addresses. In this form is a subform called
"Grades" that displays the classes passed and credit points earned for the student in the main form.
How can the Students form refer to the Credits control in the Grades subform?
Access refers to open forms with the Forms prefix:
Forms.

Using the dot as a separator, the Surname control on the Students form can be referenced like this:
Forms.Students.Surname

If there are spaces in the names of your objects, you will need to use square brackets around the
names like this:
Forms.[Students Form].[First Name]

Now, the area on a form that contains a subform is actually a control too, and needs to be identified
and named in your code.
This control has a .form property which refers to the form that it holds. This .form property must be
included if you wish to refer to controls in the subform.
Forms.Students.Grades.Form.Credits

where Students is the name of the parent form, Grades is the name of the control that holds the
subform, and Credits is a control on the subform.
Once you get the hang of referring to things this way it is really simple to understand and you will
think of more and more uses;

To SetValue in code for fields


To print a report limited to a certain financial period (the WHERE clause in the OpenReport
action)
To control buttons on subforms

In code, you can also use Me and Parent to shorten the references.

ENTER VALUE AS A PERCENT


When you set a field's Format property to "Percent" and enter 7, Access interprets it as 700%. How
do you get it to interpret it as 7% without having to type the percent sign for every entry?
Use the AfterUpdate event of the control to divide by 100. But then if the user did type "7%", your
code changes it to 0.07%! We need to divide by 100 only if the user did not type the percent sign.
To do that, examine the Text property of the control. Unlike the control's Value, the Text property
is the text as you see it.

USING THE CODE


You will need to create a new Module in your database called PercentConvert.
Choose the Modules tab of the Database window.
Click New to open a module.
Copy the code below, and paste into your module.
Save the module with the name PercentConvert

Public Function MakePercent(txt As TextBox)


On Error GoTo Err_Handler
'Purpose: Divide the value by 100 if no percent sign found.
'Usage:
Text23 to:

Set the After Update property of a text box named

'

=MakePercent([Text23])

If Not IsNull(txt) Then


If InStr(txt.Text, "%") = 0 Then
txt = txt / 100
End If
End If

Exit_Handler:
Exit Function

Err_Handler:
If Err.Number <> 2185 Then
has focus.

'No Text property unless control

MsgBox "Error " & Err.Number & " - " & Err.Description
End If
Resume Exit_Handler
End Function

Apply your code to a textbox named Text23.


Open a form in design view. Add a new textbox to your form.
Right-click the text box, and choose Properties. Make sure the name of the textbox is Text23 (if it
has a different name, your code will not run correctly.
Set the After Update property of the text box to =MakePercent([Text23])

SCROLL RECORDS WITH THE MOUSE WHEEL


In earlier versions of Access, scrolling the mouse (using the scrolling wheel) jumped records. This
caused a range of problems:

Incomplete records were saved


People were confused about why their record disappeared

You can disable the mouse wheel in Form view, and scroll records in Datasheet and Continuous
view.

CREATE THE CODE


On the Create tab of the ribbon, in the Other group, click the arrow below Macro, and
choose Module. Access will open a new module.
Use the code below for your new Module. To verify Access understands it, choose Compile on
the Debug menu.

Public Function DoMouseWheel(frm As Form, lngCount As Long) As


Integer
On Error GoTo Err_Handler
'Purpose:
Make the MouseWheel scroll in Form View in
Access 2007 and later.
'
versions.

This code lets Access 2007 behave like older

'Return:
1 if moved forward a record, -1 if moved back a
record, 0 if not moved.
'Author:

Allen Browne, February 2007.

'Usage:

In the MouseWheel event procedure of the form:

'

Call DoMouseWheel(Me, Count)

Dim strMsg As String


'Run this only in Access 2007 and later, and only in Form
view.
If (Val(SysCmd(acSysCmdAccessVer)) >= 12#) And
(frm.CurrentView = 1) And (lngCount <> 0&) Then

'Save any edits before moving record.


RunCommand acCmdSaveRecord
'Move back a record if Count is negative, otherwise
forward.
RunCommand IIf(lngCount < 0&, acCmdRecordsGoToPrevious,
acCmdRecordsGoToNext)
DoMouseWheel = Sgn(lngCount)
End If

Exit_Handler:
Exit Function

Err_Handler:
Select Case Err.Number
Case 2046&
last, etc.

'Can't move before first, after

Beep
Case 3314&, 2101&, 2115&

'Can't save the current record.

strMsg = "Cannot scroll to another record, as this one


can't be saved."
MsgBox strMsg, vbInformation, "Cannot scroll"
Case Else
strMsg = "Error " & Err.Number & ": " & Err.Description
MsgBox strMsg, vbInformation, "Cannot scroll"
End Select
Resume Exit_Handler
End Function

Save the module, with a name such as modMouseWheel.


Open your form in design view. On the Event tab of the Properties sheet, set the On Mouse
Wheel property to [Event Procedure]

Click the Build button (...) beside the property. Access opens the code window. Between the Private
Sub ... and End Sub lines, enter Call DoMouseWheel(Me, Count)
Repeat steps 4 and 5 for your other forms.

HOW IT WORKS
The function accepts two arguments:

A reference to the form (which will be the active form if the mouse is scrolling it), and
The value of Count (a positive number if scrolling forward, or negative if scrolling back.)

Firstly, the code tests the Access version is at least 12 (the internal version number for Access 2007),
and the form is in Form view. It does nothing in a previous version or in another view where the
mouse scroll still works. It also does nothing if the count is zero, i.e. neither scrolling forward nor
back.
Before you can move record, Access must save the current record. Explicitly saving is always a good
idea, as this clears pending events. If the record cannot be saved (e.g. required field missing), the
line generates an error and drops to the error hander which traps the common issues.
The highlighted RunCommand moves to the previous record if the Count is negative, or the next
record if positive. This generates error 2046 if you try to scroll up above the first record, or down
past the last one. Again the error handler traps this error.
Finally we set the return value to the sign of the Count argument, so the calling procedure can tell
whether we moved record.

AVOID #ERROR IN FORM/REPORT WITH NO RECORDS


Calculated expressions show #Error when a form or report has no records. This is known as a Hash
Error.
This sort-of makes sense for a developer - if the controls don't exist, you cannot sum them. But
seeing this type of error can be confising for the user, so the obvious thing to do is eliminate this
type of errot.

IN FORMS
The problem does not arise in forms that are displaying a new record (in other words the form is
ready to accept data for a new record).
You will find it does occur if the form's Allow Additions property is Yes, or if the form is bound to a
non-updatable query.
To avoid the problem, test the RecordCount of the form's Recordset. In older versions of Access,
that meant changing:

=Sum([Amount])

to:
=IIf([Form].[Recordset].[RecordCount] > 0, Sum([Amount]), 0)

This wont work in newer versions of Access. You will need a new Function to take care of this error.

CODE IT YOUTSELF
Copy this function into a standard module, and save the module with a name such as modHashError

Public Function FormHasData(frm As Form) As Boolean


'Purpose:
than new one).

Return True if the form has any records (other

'
no records.

Return False for unbound forms, and forms with

'Note:
cannot use:

Avoids the bug in Access 2007 where text boxes

'

[Forms].[Form1].[Recordset].[RecordCount]

On Error Resume Next

'To handle unbound forms.

FormHasData = (frm.Recordset.RecordCount <> 0&)


End Function

Now use this expression in the Control Source of the text box:
=IIf(FormHasData([Form]), Sum([Amount]), 0)

IN REPORTS
Use the HasData property specifically for this purpose.
So, instead of:
=Sum([Amount])

use:
=IIf([Report].[HasData], Sum([Amount]), 0)

If you have many calculated controls, you need to do this on each one. But note, if Access discovers
one calculated control that it cannot resolve, it gives up on calculating the others. Therefore one bad
expression can cause other calculated controls to display #Error, even if those controls are bound
to valid expressions.

LIMITING A REPORT TO A DATE RANGE


There are two methods to limit the records in a report to a user-specified range of dates.

METHOD 1: PARAMETER QUERY


The simplest approach is to base the report on a parameter query. This approach works for all kinds
of queries, but has these disadvantages:

Inflexible: both dates must be entered


Inferior interface: two separate dialog boxes pop up
No way to supply defaults
No way to validate the dates

To create the parameter query you need to create a new query to use as the RecordSource of your
report.
In query design view, in the Criteria row under your date field, enter:
>= [StartDate] < [EndDate] + 1

Choose Parameters from the Query menu, and declare two parameters of type Date/Time:
StartDate

Date/Time

EndDate

Date/Time

To display the limiting dates on the report, open your report in Design View, and add two text boxes
to the Report Header section. Set their ControlSource property
to =StartDateand =EndDate respectively.

METHOD 2: FORM FOR ENTERING THE DATES


The alternative is to use a small unbound form where the user can enter the limiting dates. This
approach may not work if the query aggregates data, but has the following advantages:

Flexible: user does not have to limit report to from and to dates.
Better interface: allows defaults and other mechanisms for choosing dates.
Validation: can verify the date entries.

Here are the steps. This example assumes a report named rptSales, limited by values in
the SaleDate field.
Create a new form that is not bound to any query or table. Save with the name frmWhatDates.
Add two text boxes, and name them txtStartDate and txtEndDate. Set their Format property
to Short Date, so only date entries will be accepted.
Add a command button, and set its Name property to cmdPreview.
Set the button's On Click property to [Event Procedure] and click the Build button (...)
beside this. Access opens the code window.
Between the "Private Sub..." and "End Sub" lines paste in the code below.

Private Sub cmdPreview_Click()


'On Error GoTo Err_Handler
'Remove the single quote from
start of this line once you have it working.
'Purpose:

Filter a report to a date range.

'Documentation: http://allenbrowne.com/casu-08.html
'Note:
Filter uses "less than the next day" in case
the field has a time component.
Dim strReport As String
Dim strDateField As String
Dim strWhere As String
Dim lngView As Long
Const strcJetDate = "\#mm\/dd\/yyyy\#"
match your local settings.

'Do NOT change it to

'DO set the values in the next 3 lines.


strReport = "rptSales"
quotes.

'Put your report name in these

strDateField = "[SaleDate]" 'Put your field name in the


square brackets in these quotes.
lngView = acViewPreview
instead of preview.

'Use acViewNormal to print

'Build the filter string.


If IsDate(Me.txtStartDate) Then
strWhere = "(" & strDateField & " >= " &
Format(Me.txtStartDate, strcJetDate) & ")"
End If
If IsDate(Me.txtEndDate) Then
If strWhere <> vbNullString Then
strWhere = strWhere & " AND "
End If
strWhere = strWhere & "(" & strDateField & " < " &
Format(Me.txtEndDate + 1, strcJetDate) & ")"
End If

'Close the report if already open: otherwise it won't filter


properly.
If CurrentProject.AllReports(strReport).IsLoaded Then
DoCmd.Close acReport, strReport
End If

'Open the report.


'Debug.Print strWhere
'Remove the single quote from
the start of this line for debugging purposes.
DoCmd.OpenReport strReport, lngView, , strWhere

Exit_Handler:
Exit Sub

Err_Handler:
If Err.Number <> 2501 Then
MsgBox "Error " & Err.Number & ": " & Err.Description,
vbExclamation, "Cannot open report"

End If
Resume Exit_Handler
End Sub

Open the report in Design View, and add two text boxes to the report header for displaying the date
range. Set the ControlSource for these text boxes to:
=Forms.frmWhatDates.txtStartDate
=Forms.frmWhatDates.txtEndDate

Now when you click the Ok button, the filtering works like this:

both start and end dates found: filtered between those dates;
only a start date found: records from that date onwards;
only an end date found: records up to that date only;
neither start nor end date found: all records included.

You will end up using this form for all sorts of reports. You may add an option group or list box that
selects which report you want printed, and a check box that determines whether the report should
be opened in preview mode.

PRINT THE RECORD IN THE FORM


How do you print just the one record you are viewing in the form?
Create a report, to get the layout right for printing. Use the primary key value that uniquely
identifies the record in the form, and open the report with just that one record.

The steps
Open your form in design view.
Click the command button in the toolbox (Access 1 - 2003) or on the Controls group of the Design
ribbon (Access 2007 and 2010), and click on your form.
If the wizard starts, cancel it. It will not give you the flexibility you need.
Right-click the new command button, and choose Properties. Access opens the Properties box.
On the Other tab, set the Name to something like: cmdPrint
On the Format tab, set the Caption to the text you wish to see on the button, or the Picture if you
would prefer a printer or preview icon.
On the Event tab, set the On Click property to: [Event Procedure]
Click the Build button (...) beside this. Access opens the code window.
Paste the code below into the procedure. Replace ID with the name of your primary key field,
and MyReport with the name of your report.

The code

Private Sub cmdPrint_Click()


Dim strWhere As String

If Me.Dirty Then

'Save any edits.

Me.Dirty = False
End If

If Me.NewRecord Then 'Check there is a record to print


MsgBox "Select a record to print"

Else
strWhere = "[ID] = " & Me.[ID]
DoCmd.OpenReport "MyReport", acViewPreview, , strWhere
End If
End Sub

BRING THE TOTAL FROM A SUBREPORT BACK ONTO


THE MAIN REPORT
Your subreport has a total at the end - a text box in the Report Footer section, with a Control Source like this:
=Sum([Amount])
Now, how do you pass that total back to the the main report?

Stage 1
If the subreport is called Sub1, and the text box is txtTotal, put the text box on your main report, and start
with this Control Source:
=[Sub1].[Report].[txtTotal]

Stage 2
Check that it works. It should do if there are records in the subreport. If not, you get #Error. To avoid that, test
the HasData property, like this:
=IIf([Sub1].[Report].[HasData], [Sub1].[Report].[txtTotal], 0)

Stage 3
The subreport total could be Null, so you might like to use Nz() to convert that case to zero also:
=IIf([Sub1].[Report].[HasData], Nz([Sub1].[Report].[txtTotal], 0), 0)

Troubleshooting
If you are stuck at some point, these further suggestions might help.

Total does not work in the subreport


If the basic =Sum([Amount]) does not work in the subreport:
Make sure the total text box is in the Report Footer section, not the Page Footer section.
Make sure the Name of this text box is not the same as the name of a field (e.g. it cannot be called Amount.)
The field you are trying to sum must be a field in the report's source table/query. If Amount is a calculated text
box such as:
=[Quantity]*[PriceEach]
then repeat the whole expression in the total box, e.g.:
=Sum([Quantity]*[PriceEach])
Make sure that what you are trying to sum is a Number, not text. See Calculated fields misinterpreted.

Stage 1 does not work


If the basic expression at Stage 1 above does not work:
Open the main report in design view.
Right-click the edge of the subform control, and choose Properties.

Check the Name of the subreport control (on the Other tab of the Properties box.)
The Name of the subreport control can be different than the name of the report it contains (its Source Object.)
Uncheck the Name AutoCorrect boxes under:
Tools | Options | General
For details of why, see Failures caused by Name Auto-Correct

Stage 2 does not work


If Stage 2 does not work but Stage 1 does, you must provide 3 parts for IIf():
an expression that can be True or False (the HasData property in our case),
an expression to use when the first part is True (the value from the subreport, just like Stage 1),
an expression to use when the first part is False (a zero.)

NUMBERING ENTRIES IN A REPORT OR FORM


Report
There is a very simple way to number records sequentially on a report. It always works regardless how the
report is sorted or filtered.
With your report open in Design View:
From the Toolbox (Access 1 - 2003) or the Controls group of the Design ribbon (Access 2007 and later), add a
text box for displaying the number.
Select the text box, and in the Properties Window, set these properties:
Control Source

=1

Running Sum

Over Group

That's it! This text box will automatically increment with each record.

Form
Casual users sometimes want to number records in a form as well, e.g. to save the number of a record so as to
return there later. Don't do it! Although Access does show "Record xx ofyy" in the lower left ofthe form, this
number can change for any number of reasons, such as:
The user clicks the "A-Z" button to change the sort order;
The user applies a filter;
A new record is inserted;
An old record is deleted.
In relational database theory, the records in a table cannot have any physical order, so record numbers
represent faulty thinking. In place of record numbers, Access uses the Primary Key of the table, or the
Bookmark of a recordset. If you are accustomed from another database and find it difficult to conceive of life
without record numbers, check out What, no record numbers?
You still want to refer to the number of a record in a form as currently filtered and sorted? There are ways to
do so. In Access 97 or later, use the form's CurrentRecord property, by adding a text box with this expression
in the ControlSource property:

=[Form].[CurrentRecord]
In Access 2, open your form in Design View in design view and follow these steps:
From the Toolbox, add a text box for displaying the number.
Select the text box, and in the Properties Window, set its Name to txtPosition. Be sure to leave
the Control Source property blank.
Select the form, and in the Properties Window set the On Current property to [Event Procedure] .

Click the "..." button beside this. Access opens the Code window.
Between the lines Sub Form_Current() and End Sub, paste these lines:

On Error GoTo Err_Form_Current


Dim rst As Recordset

Set rst = Me.RecordsetClone


rst.Bookmark = Me.Bookmark
Me.txtPosition = rst.AbsolutePosition + 1

Exit_Form_Current:
Set rst = Nothing
Exit Sub

Err_Form_Current:
If Err = 3021 Then

'No current record

Me.txtPosition = rst.RecordCount + 1
Else
MsgBox Error$, 16, "Error in Form_Current()"
End If
Resume Exit_Form_Current

The text box will now show a number matching the one between the NavigationButtons on your form.

Query
For details of how to rank records in a query, see Ranking in a Query

Hide duplicates selectively


This article explains how to use the IsVisible property in conjunction with HideDuplicates to selectively hide
repeating values on a report.
Relational databases are full of one-to-many relations. In Northwind, one Order can have many Order Details.
So, in queries and reports, fields from the "One" side of the relation repeat on every row like this:

The HideDuplicates property (on the Format tab of the Properties sheet) helps. Setting HideDuplicates to Yes
for OrderID, OrderDate, and CompanyName, gives a more readable report, but is not quite right:

The Date and Company for Order 10617 disappeared, since they were the same the previous order. Similarly,
the company name is hidden in order 10619. How can we suppress the date and company only when
repeating the same order, but show them for a new order even if they are the same as the previous row?
When Access hides duplicates, it sets a special property named IsVisible. By testing the IsVisible property of
the OrderID, we can hide the OrderDate and CompanyName only when the OrderID changes.
Set the properties of the OrderID text box like this:
Control Source . . . =IIf(OrderID.IsVisible,[OrderDate],Null)
Hide Duplicates . . . No
Name . . . . . . . . .

txtOrderDate

The Control Source tests the IsVisible property of the OrderID. If it is visible, then the control shows the
OrderDate. If it is not visible, it shows Null. Leave the HideDuplicates property turned off. We must change the
name as well, because Access gets confused if a control has the same name as a field, but is bound to
something else.
Similarly, set the ControlSource of the CompanyName text box to:
=IIf(OrderID.IsVisible,[CompanyName],Null)
and change its name to (say) txtCompanyName.
Now the report looks like this:

Note that the IsVisible property is not the same as the Visible property in the Properties box. IsVisible is not
available at design time. Access sets it for you when the report runs, for exactly the purpose explained in this
article.
If you are trying to create the sample report above in the Northwind sample database, here is the query it is
based on:
SELECT Orders.OrderID, Orders.OrderDate, Customers.CompanyName, [Order Details].ProductID, Products.ProductName, [Order Details].Quantity
FROM Products INNER JOIN ((Customers INNER JOIN Orders ON Customers.CustomerID=Orders.CustomerID) INNER JOIN [Order Details]
ON Orders.OrderID=[Order Details].OrderID) ON Products.ProductID=[Order Details].ProductID
WHERE Orders.OrderID > 10613
ORDER BY Orders.OrderID;

In summary, use HideDuplicates where you do want duplicates hidden, but for other controls that should hide
at the same time, test the IsVisible property in their ControlSource.

GETTING A VALUE FROM A TABLE: DLOOKUP()


Sooner or later, you will need to retrieve a value stored in a table. If you regularly make write invoices to
companies, you will have a Company table that contains all the company's details including a CompanyID field,
and a Contract table that stores just the CompanyID to look up those details. Sometimes you can base your
form or report on a query that contains all the additional tables. Other times, DLookup() will be a life-saver.
DLookup() expects you to give it three things inside the brackets. Think of them as:

Look up the _____ field, from the _____ table, where the record is _____
Each of these must go in quotes, separated by commas.
You must also use square brackets around the table or field names if the names contain odd characters
(spaces, #, etc) or start with a number.
This is probably easiest to follow with some examples:
you have a CompanyID such as 874, and want to print the company name on a report;
you have Category such as "C", and need to show what this category means.
you have StudentID such as "JoneFr", and need the student?s full name on a form.

Example 1:
Look up the CompanyName field from table Company, where CompanyID = 874. This translates to:

=DLookup("CompanyName", "Company", "CompanyID = 874")


You don't want Company 874 printed for every record! Use an ampersand (&) to concatenate the current
value in the CompanyID field of your report to the "Company = " criteria:

=DLookup("CompanyName", "Company", "CompanyID = " & [CompanyID])


If the CompanyID is null (as it might be at a new record), the 3rd agumenent will be incomplete, so the entire
expression yields #Error. To avoid that use Nz() to supply a value for when the field is null:

=DLookup("CompanyName", "Company", "CompanyID = " & Nz([CompanyID],0))

Example 2:
The example above is correct if CompanyID is a number. But if the field is text, Access expects quote
marks around it. In our second example, we look up the CategoryName field in table Cat, where Category =
'C'. This means the DLookup becomes:

=DLookup("CategoryName", "Cat", "Category = 'C'")


Single quotes within the double quotes is one way to do quotes within quotes. But again, we don't want
Categoy 'C' for all records: we need the current value from our Category field patched into the quote. To do
this, we close the quotation after the first single quote, add the contents of Category, and then add
the trailing single quote. This becomes:

=DLookup("CategoryName", "Cat", "Category = '" & [Category] & "'")

Example 3:
In our third example, we need the full name from a Student table. But the student table has the name split
into FirstName and Surname fields, so we need to refer to them both and add a space between. To show this
information on your form, add a textbox with ControlSource:

=DLookup("[FirstName] & ' ' & [Surname]", "Student", "StudentID = '" & [StudentID] & "'")

Quotes inside quotes


Now you know how to supply the 3 parts for DLookup(), you are using quotes inside quotes. The single quote
character fails if the text contains an apostrophe, so it is better to use the double-quote character. But you
must double-up the double-quote character when it is inside quotes.

LOCKING BOUND CONTROLS


It is very easy to overwrite data accidentally in Access. Setting a form's AllowEdits property prevents that, but
also locks any unbound controls you want to use for filtering or navigation. This solution locks only the bound
controls on a form and handles its subforms as well.

First, the code saves any edits in progress, so the user is not stuck with a half-edited form. Next it loops
through all controls on the form, setting the Locked property of each one unlessthe control:
is an unsuitable type (lines, labels, ...);
has no Control Source property (buttons in an option group);
is bound to an expression (Control Source starts with "=");
is unbound (Control Source is blank);
is named in the exception list. (You can specify controls you do not want unlocked.)
If it finds a subform, the function calls itself recursively. Nested subforms are therefore handled to any depth.
If you do not want your subform locked, name it in the exception list.
The form's AllowDeletions property is toggled as well. The code changes the text on the command button to
indicate whether clicking again will lock or unlock.
To help the user remember they must unlock the form to edit, add a rectangle named rctLock around the edge
of your form. The code shows this rectangle when the form is locked, and hides it when unlocked.

Using with your forms


To use the code:
Open a new module. In Access 95 - 2003, click the Modules tab of the Database window, and click New. In
Access 2007 and later, click Module (rightmost icon) on the Create ribbon. Access opens a code module.
Paste in the code from the end of this article.
Save the module with a name such as ajbLockBound.

(Optional) Add a red rectangle to your form to indicate it is locked. Name it rctLock.
To initialize the form so it comes up locked, set the On Load property of your form to:
=LockBoundControls([Form],True)
Add a command button to your form. Name it cmdLock.
Set its On Click property to [Event Procedure].
Click the Build button (...) beside this.
Set up the code like this:

Private Sub cmdLock_Click()


Dim bLock As Boolean
bLock = IIf(Me.cmdLock.Caption = "&Lock", True, False)
Call LockBoundControls(Me, bLock)
End Sub

(Optional) Add the names of any controls you do not want unlocked at steps 3 and 4. For example, to avoid
unlocking controls EnteredOn and EnteredBy in the screenshot above, you would use:
Call LockBoundControls(Me, bLock, "EnteredOn", "EnteredBy")
Note that if your form has any disabled controls, changing their Locked property affects the way they look. To
avoid this, add them to the exception list.

The code
Public Function LockBoundControls(frm As Form, bLock As Boolean, ParamArray avarExceptionList())
On Error GoTo Err_Handler
'Purpose:
Lock the bound controls and prevent deletes on
the form any its subforms.
'Arguments

frm = the form to be locked

'

bLock = True to lock, False to unlock.

'
avarExceptionList: Names of the controls NOT to
lock (variant array of strings).
'Usage:

Call LockBoundControls(Me. True)

Dim ctl As Control

'Each control on the form

Dim lngI As Long

'Loop controller.

Dim bSkip As Boolean

'Save any edits.


If frm.Dirty Then
frm.Dirty = False
End If
'Block deletions.
frm.AllowDeletions = Not bLock

For Each ctl In frm.Controls


Select Case ctl.ControlType
Case acTextBox, acComboBox, acListBox, acOptionGroup,
acCheckBox, acOptionButton, acToggleButton
'Lock/unlock these controls if bound to fields.
bSkip = False
For lngI = LBound(avarExceptionList) To
UBound(avarExceptionList)
If avarExceptionList(lngI) = ctl.Name Then
bSkip = True
Exit For
End If
Next
If Not bSkip Then
If HasProperty(ctl, "ControlSource") Then
If Len(ctl.ControlSource) > 0 And Not
ctl.ControlSource Like "=*" Then
If ctl.Locked <> bLock Then
ctl.Locked = bLock
End If
End If
End If

End If

Case acSubform
'Recursive call to handle all subforms.
bSkip = False
For lngI = LBound(avarExceptionList) To
UBound(avarExceptionList)
If avarExceptionList(lngI) = ctl.Name Then
bSkip = True
Exit For
End If
Next
If Not bSkip Then
If Len(Nz(ctl.SourceObject, vbNullString)) > 0
Then
ctl.Form.AllowDeletions = Not bLock
ctl.Form.AllowAdditions = Not bLock
Call LockBoundControls(ctl.Form, bLock)
End If
End If

Case acLabel, acLine, acRectangle, acCommandButton,


acTabCtl, acPage, acPageBreak, acImage, acObjectFrame
'Do nothing

Case Else
'Includes acBoundObjectFrame, acCustomControl
Debug.Print ctl.Name & " not handled " & Now()
End Select
Next

'Set the visual indicators on the form.


On Error Resume Next
frm.cmdLock.Caption = IIf(bLock, "Un&lock", "&Lock")
frm!rctLock.Visible = bLock

Exit_Handler:
Set ctl = Nothing
Exit Function

Err_Handler:
MsgBox "Error " & Err.Number & " - " & Err.Description
Resume Exit_Handler
End Function

Public Function HasProperty(obj As Object, strPropName As


String) As Boolean
'Purpose:

Return true if the object has the property.

Dim varDummy As Variant


On Error Resume Next
varDummy = obj.Properties(strPropName)
HasProperty = (Err.Number = 0)
End Function

NULLS: DO I NEED THEM?


Why have Nulls?
Learning to handle Nulls can be frustrating. Occasionally I hear newbies ask, "How can I prevent them?" Nulls
are a very important part of your database, and it is essential that you learn to handle them.
A Null is "no entry" in a field. The alternative is to require an entry in every field of every record! You turn up
at a hospital too badly hurt to give your birth date, and they won't let you in because the admissions database
can't leave the field null? Since some fields must be optional, so you must learn to handle nulls.
Nulls are not a problem invented by Microsoft Access. They are a very important part of relational database
theory and practice, part of any reasonable database. Ultimately you will come to see the Null as your friend.
Think of Null as meaning Unknown.

Null is not the same as zero


Open the Immediate Window (press Ctrl+G), and enter:

? Null = 0
VBA responds, Null. In plain English, you asked VBA, Is an Unknown equal to Zero?, and VBA responded with, I
don't know. Null is not the same as zero.
If an expression contains a Null, the result is often Null. Try:

? 4 + Null
VBA responds with Null, i.e. The result is Unknown. The technical name for this domino effect is Null
propagation.
Nulls are treated differently from zeros when you count or average a field. Picture a table with
an Amount field and these values in its 3 records:

4, 5, Null
In the Immediate window, enter:

? DCount("Amount", "MyTable")
VBA responds with 2. Although there are three records, there are only two known values to report. Similarly, if
you ask:

? DAvg("Amount", "MyTable")
VBA responds with 4.5, not 3. Nulls are excluded from operations such as sum, count, and average.
Hint: To count all records, use Count("*") rather than Count("[SomeField]"). That way Access can respond with
the record count rather than wasting time checking if there are nulls to exclude.

Null is not the same as a zero-length string


VBA uses quote marks that open and immediately close again to represent a string with nothing in it. If you
have no middle name, it could be represented as a zero-length string. That is not the same as saying your
middle name is unknown (Null). To demonstrate the difference, enter this into the Immediate window:

? Len(""), Len(Null)
VBA responds that the length of the first string is zero, but the length of the unknown is unknown (Null).
Text fields in an Access table can contain a zero-length string to distinguish Unknown from Non-existent.
However, there is no difference visible to the user, so you are likely to confuse the user (as well as the typical
Access developer.) Recent versions of Access default this property to Yes: we recommend you change this
property for all Text and Memo fields. Details and code in Problem Properties.

Null is not the same as Nothing or Missing


These are terms that sound similar but mean do not mean the same as Null, the unknown value.
VBA uses Nothing to refer to an unassigned object, such as a recordset that has been declared but not set.
VBA uses Missing to refer to an optional parameter of a procedure.
To help you avoid common traps in handling nulls, see: Common Errors with Null

COMMON ERRORS WITH NULL


Here are some common mistakes newbies make with Nulls.

Error 1: Nulls in Criteria


If you enter criteria under a field in a query, it returns only matching records. Nulls are excluded when you
enter criteria.
For example, say you have a table of company names and addresses. You want two queries: one that gives
you the local companies, and the other that gives you all the rest. In the Criteria row under the City field of
the first query, you type:

"Springfield"
and in the second query:

Not "Springfield"
Wrong! Neither query includes the records where City is Null.

Solution
Specify Is Null. For the second query above to meet your design goal of "all the rest", the criteria needs to be:

Is Null Or Not "Springfield"


Note: Data Definition Language (DDL) queries treat nulls differently. For example, the nulls are counted in this kind of query:
ALTER TABLE Table1 ADD CONSTRAINT chk1 CHECK (99 < (SELECT Count(*) FROM Table2 WHERE Table2.State <> 'TX'));

Error 2: Nulls in expressions


Maths involving a Null usually results in Null. For example, newbies sometimes enter an expression such as this
in the ControlSource property of a text box, to display the amount still payable:

=[AmountDue] - [AmountPaid]
The trouble is that if nothing has been paid, AmountPaid is Null, and so this text box displays nothing at all.

Solution
Use the Nz() function to specify a value for Null:

= Nz([AmountDue], 0) - Nz([AmountPaid], 0)

Error 3: Nulls in Foreign Keys

While Access blocks nulls in primary keys, it permits nulls in foreign keys. In most cases, you should explicitly
block this possibility to prevent orphaned records.
For a typical Invoice table, the line items of the invoice are stored in an InvoiceDetail table, joined to the
Invoice table by an InvoiceID. You create a relationship between Invoice.InvoiceID and
InvoiceDetail.InvoiceID, with Referential Integrity enforced. It's not enough!
Unless you set the Required property of the InvoiceID field to Yes in the InvoiceDetail table, Access permits
Nulls. Most often this happens when a user begins adding line items to the subform without first creating the
invoice itself in the main form. Since these records don't match any record in the main form, these orphaned
records are never displayed again. The user is convinced your program lost them, though they are still there in
the table.

Solution
Always set the Required property of foreign key fields to Yes in table design view, unless you expressly want
Nulls in the foreign key.

Error 4: Nulls and non-Variants


In Visual Basic, the only data type that can contain Null is the Variant. Whenever you assign the value of a field
to a non-variant, you must consider the possibility that the field may be null. Can you see what could go wrong
with this code in a form's module?

Dim strName as String


Dim lngID As Long
strName = Me.MiddleName
lngID = Me.ClientID
When the MiddleName field contains Null, the attempt to assign the Null to a string generates an error.
Similarly the assignment of the ClientID value to a numeric variable may cause an error. Even if ClientID is the
primary key, the code is not safe: the primary key contains Null at a new record.

Solutions
(a) Use a Variant data type if you need to work with nulls.
(b) Use the Nz() function to specify a value to use for Null. For example:

strName = Nz(Me.MiddleName, "")


lngID = Nz(Me.ClientID, 0)

Error 5: Comparing something to Null

The expression:

If [Surname] = Null Then


is a nonsense that will never be True. Even if the surname is Null, VBA thinks you asked:

Does Unknown equal Unknown?


and always responds "How do I know whether your unknowns are equal?" This is Null propagation again: the
result is neither True nor False, but Null.

Solution
Use the IsNull() function:

If IsNull([Surname]) Then

Error 6: Forgetting Null is neither True nor False.


Do these two constructs do the same job?

(a)

If [Surname] = "Smith" Then


MsgBox "It's a Smith"
Else
MsgBox "It's not a Smith"
End If

(b)

If [Surname] <> "Smith" Then


MsgBox "It's not a Smith"
Else
MsgBox "It's a Smith"
End If

When the Surname is Null, these 2 pieces of code contradict each other. In both cases, the If fails, so
the Else executes, resulting in contradictory messages.

Solutions
(a) Handle all three outcomes of a comparison - True, False, and Null:

If [Surname] = "Smith" Then

MsgBox "It's a Smith"


ElseIf [Surname] <> "Smith" Then
MsgBox "It's not a Smith"
Else
MsgBox "We don't know if it's a Smith"
End If
(b) In some cases, the Nz() function lets you to handle two cases together. For example, to treat a Null and a
zero-length string in the same way:

If Len(Nz([Surname],"")) = 0 Then

PROBLEM PROPERTIES
Recent versions of Access have introduced new properties or changed the default setting for existing
properties. Accepting the new defaults causes failures, diminished integrity, performance loss, and exposes
your application to tinkerers.

Databases: Name AutoCorrect


Any database created with Access 2000 or later, has the Name AutoCorrect properties on. You must
remember to turn it off for every new database you create:
In Access 2010, click File | Options | Current Database, and scroll down to Name AutoCorrect Options.
In Access 2007, click the Office Button | Access Options | Current Database, and scroll down to Name
AutoCorrect Options.
In Access 2000 - 2003, the Name AutoCorrect boxes are under Tools | Options | General.
The problems associated with this property are wide-ranging. For details, see: Failures caused by Name
Auto-Correct.
You may also wish to turn off Record-level locking:
In Access 2010: File | Options | Advanced.
In Access 2007: Office Button | Access Options | Advanced.
In Access 2000 - 2003: Tools | Options | Advanced.
Although record-level locking may be desirable in some heavily networked applications, there is a performance
hit. Even more significantly, if you have attached tables from Access 97 or earlier and record-level locking is
enabled, some DAO transactions may fail. (The scenario that uncovered this bug involved de-duplicating clients
- reassigning related records, and then removing the duplicate.)
In Access 2007 and later, you will also want to uncheck the box labelled Enable design changes for tables in
Datasheet view (for this database) under File (Office Button) | Access Options | Current Database. In Access
2007 and later you can create a template database that sets these settings for every new database. For details,
see Default forms, reports and databases.

Fields: Allow Zero Length


Table fields created in Access 97 had their Allow Zero Length property set to No by default. In Access 2000 and
later, the property defaults to Yes, and you must remember to turn it off every time you add a field to a table.
To the end user, there is no visible difference between a zero-length string (ZLS) and a Null, and the distinction
should not be forced upon them. The average Access developer has enough trouble validating Nulls without
having to handle the ZLS/Null distinction as well in every event procedure of their application. The savvy
developer uses engine-level validation wherever possible, and permits a ZLS only in rare and specific
circumstances.
There is no justification for having this property on by default. There is no justification for the inconsistency
with previous versions.

Even Access itself gets the distinction between Null and ZLS wrong: DLookup() returns Null when it should
yield a ZLS.
You must therefore set this property for every field in the database where you do not wish to explicitly permit
a ZLS. To save you doing so manually, this code loops through all your tables, and sets the property for each
field:

Function FixZLS()
Dim db As DAO.Database
Dim tdf As DAO.TableDef
Dim fld As DAO.Field
Dim prp As DAO.Property
Const conPropName = "AllowZeroLength"
Const conPropValue = False

Set db = CurrentDb()
For Each tdf In db.TableDefs
If (tdf.Attributes And dbSystemObject) = 0 Then
If tdf.Name <> "Switchboard Items" Then
For Each fld In tdf.Fields
If fld.Properties(conPropName) Then
Debug.Print tdf.Name & "." & fld.Name
fld.Properties(conPropName) =
conPropValue
End If
Next
End If
End If
Next

Set prp = Nothing


Set fld = Nothing

Set tdf = Nothing


Set db = Nothing
End Function

How crazy is this? We are now running code to get us back to the functionality we had in previous versions?
And you have to keep remembering to set these properties with any structural changes? This is enhanced
usability?
If you create fields programmatically, be aware that these field properties are set inconsistently. The setting
you get for Allow Zero Length, Unicode Compression, and other properties depends on whether you use DAO,
ADOX, or DDL to create the field.
Prior to Access 2007, numeric fields always defaulted to zero, so you had to manually remove the Default
Value whenever you created a Number type field. It was particularly important to do so for foreign key fields.

Tables: SubdatasheetName
In Access 2000, tables got a new property called SubdatasheetName. If the property is not set, it defaults to
"[Auto]". Its datasheet displays a plus sign which the user can click to display related records from some other
table that Access thinks may be useful.
This automatically assigned property is inherited by forms and subforms displayed in datasheet view. Clearly,
this is not a good idea and may have unintended consequences in applications imported from earlier versions.
Worse still, there are serious performance issues associated with loading a form that has several subforms
where Access is figuring out and collecting data from multiple more related tables.
Again, the solution is to turn off subdatasheets by setting the property to "[None]". Again, there is no way to
do this by default, so you must remember to do so every time you create a table. This code will loop through
your tables and turn the property off:

Function TurnOffSubDataSh()
Dim db As DAO.Database
Dim tdf As DAO.TableDef
Dim prp As DAO.Property
Const conPropName = "SubdatasheetName"
Const conPropValue = "[None]"

Set db = DBEngine(0)(0)
For Each tdf In db.TableDefs
If (tdf.Attributes And dbSystemObject) = 0 Then

If tdf.Connect = vbNullString And Asc(tdf.Name) <>


126 Then 'Not attached, or temp.
If Not HasProperty(tdf, conPropName) Then
Set prp = tdf.CreateProperty(conPropName,
dbText, conPropValue)
tdf.Properties.Append prp
Else
If tdf.Properties(conPropName) <>
conPropValue Then
tdf.Properties(conPropName) =
conPropValue
End If
End If
End If
End If
Next

Set prp = Nothing


Set tdf = Nothing
Set db = Nothing
End Function

Public Function HasProperty(obj As Object, strPropName As


String) As Boolean
'Purpose:

Return true if the object has the property.

Dim varDummy As Variant

On Error Resume Next


varDummy = obj.Properties(strPropName)
HasProperty = (Err.Number = 0)
End Function

Forms: Allow Design Changes


The Allow Design Changes property for new forms defaults to True ("All Views"). This is highly undesirable for
developers. It is also undesirable for tinkerers, as there is some evidence that altering the event procedures
while the form is open (not design view) can contribute to corruption. (In Access 2007 and later, this property
seems to be removed from the Property Sheet and ignored by the interface, though it is still present and still
defaults to True.)
Again, we find ourselves having to work around the new defaults. Rather than setting these properties every
time you create a form, consider taking a few moments to create someDefault Forms and Reports.

Find Dialog
You should also be aware that the Find dialog (default form toolbar, Edit menu, or Ctrl+F) now exposes a
Replace tab. This allows users to perform bulk alterations on data without the checks normally performed by
Form_BeforeUpdate or follow-ons in Form_AfterUpdate. This seems highly undesirable in a database that
provides no triggers at the engine level.
A workaround for this behavior is to temporarily set the AllowEdits property of the form to No before
you DoCmd.RunCommand acCmdFind.

DEFAULT FORMS, REPORTS AND DATABASES


Access provides a way to set up a form and a report, and nominate them as the template for new forms and
reports:
in Access 2010: File | Access Options | Object Designers,
in Access 2007: Office Button | Access Options | Object Designers,
in Access 1 2003: Tools | Options | Forms/Reports.
That's useful, as it lets you create forms and reports quickly to your own style.
However, these forms/reports do not inherit all properties and code. You will get a better result if you copy
and paste your template form or report in the database window (Access 1 - 2003) or Nav Pane (Access 2007
and later.) The form created this way inherits all properties and event procedures.
It will take you 30-45 minutes to set up these default documents. They will save 5-15 minutes on every form or
report you create.

A default form
Create a new form, in design view. If you normally provide navigation or filtering options in the Form Header
section, display it:
in Access 2010: right-click the Detail section, and choose Form Header/Footer,
in Access 2007: Show/Hide (rightmost icon) on the Layout ribbon,
in Access 1-2003: Form Header/Footer on View menu.
Drag these sections to the appropriate height.
In addition to your visual preferences, consider setting properties such as these:

Allow Design Changes

Design View Only

Allow PivotTable View

No

Allow PivotChart View

No

Width

6"

Disallow runtime changes.


(Access 2003 and earlier.)
Disallowing these views
prevents tinkerers from trying
them from the toolbar or View
menu.
Adjust for the minimum screen
resolution you anticipate.

Now comes the important part: set the default properties for each type of control.
Select the Textbox icon in the Toolbox (Access 1 - 2003) or on the Controls group of the Design ribbon (Access
2007 and later.) The title of the Properties box reads, "Default Text Box". Set the properties that new text
boxes should inherit, such as:

Special Effect

Flat

Whatever your style is.

Font Name

MS Sans Serif

Choose a font that will definitely


be on your user's system.

Allow AutoCorrect

No

Generally you want this on for


memo fields only.

Repeat the process for the default Combo Box as well. Be sure to turn Auto Correct off - it is completely
inappropriate for Access to correct items you are selecting from a list. Set properties such as Font Name for
the default Label, Command Button, and other controls.
Add any event procedures you usually want, such as:
Form_BeforeUpdate, to validate the record;
Form_Error, to trap data errors;
Form_Close, to ensure something (such as a Switchboard) is still open.
Save the form. A name that sorts first makes it easy to copy and paste the form to create others.

A default Continuous Form


Copy and paste the form created above. This form will be the one you copy and paste to create continuous
forms.
You have already done most of the work, but the additional properties for a continuous form might include:
Set the form's Default View property to Continuous Forms.
For the default Text Box, set Add Colon to No. This will save removing the colon from each attached label
when you cut them from the Detail section and paste them into the Form Header.
If your continuous forms are usually subforms, consider adding code to cancel the form's Before Insert event if
there is no record in the parent form.
Create other "template forms" as you have need.

A default report
The default report is designed in exactly the same way as the forms above. Create a blank report, and set its
properties and the default properties for each control in the Toolbox.
Suggestions:
Set the default margins to 0.7" all round, as this copes with the Unprintable area of most printers:
In Access 2010, click Page Setup on the Page Setup ribbon.
In Access 2007, click the Extend arrow at the very bottom right of the Page Layout group on the Page
Setup ribbon.

In Access 1 - 2003, choose Page Setup from the File menu, and click the Margins tab.

Set the report's Width to 6.85". (Handles Letter and A4 with 1.4" for margins.)

Show the Report Header/Footer (View menu in Access 1 - 2003; in Access 2007, the rightmost icon in the
Show/Hide group on the Layout ribbon).
In Access 2010, right-click the Detail section, and choose Report Header/Footer.
In Access 2007, Show/Hide (rightmost icon) on the Layout ribbon.
In Access 1 - 2003, View menu.

Add a text box to the Report Header section to automatically print the report's caption as its title. Its Control
Source will be:
=[Report].[Caption]

Add a text box to the Page Footer section to show the page count. Use a Control Source of:
="Page " & [Page] & " of " & [Pages]

Set the On No Data property to:


=NoData([Report])

The last suggestion avoids displaying "#Error" when the report has no data. Copy the function below, and
paste into a general module. Using the generic function means you automatically get this protection with each
report, yet it remains lightweight (no module) which helps minimize the possibility of corruption. The code is:

Public Function NoData(rpt As Report)


'Purpose: Called by report's NoData event.
'Usage: =NoData([Report])
Dim strCaption As String

'Caption of report.

strCaption = rpt.Caption
If strCaption = vbNullString Then
strCaption = rpt.Name
End If

DoCmd.CancelEvent

MsgBox "There are no records to include in report """ & _


strCaption & """.", vbInformation, "No Data..."
End Function

A default database
In Access 2007 and later, you can also create a default database, with the properties, objects, and
configuration you want whenever you create a new (blank) database.
Click the Office Button, and click New. Enter this file name:
C:\Program Files\Microsoft Office\Templates\1033\Access\blank
and click Create. The name and location of the database are important.
If you installed Office to a different folder, locate the Templates on your computer.
To set the database properties, click the Office Button and choose Access Options.
On the Current Database tab of the dialog, uncheck the Name AutoCorrect options to prevent these bugs.
On the Object Designers tab, uncheck Enable design changes for tables in Datasheet view to prevent users
modifying your schema.
Set other preferences (such as tabbed documents or overlapping windows, and showing the Search box in the
Nav Pane.)
After setting the options, set the references you want for your new databases.
Open the code window (Alt+F11) and choose References on the Tools menu.
Import any objects you always want in a new database, such as:
the default form and report above,
modules containing your commonly used functions,
tables where you store configuration data,
your splash screen, or other commonly used forms.
To import, click the External Data tab on the ribbon, then the Import Access Database icon on
the Import group.
Now any new database you create will have these objects included, properties set, and references selected.
You can create default databases for both the new file format (accdb) and the old format (mdb) by creating
both a blank.accdb and a blank.mdb in the Access templates folder.

CALCULATING ELAPSED TIME


How do you calculate the difference between two date/time fields, such as the hours worked between clockon and clock-off?
Use DateDiff() to calculate the elapsed time. It returns whole numbers only, so if you want hours and
fractions of an hour, you must work in minutes. If you want minutes and seconds, you must get the difference
in seconds.
Let's assume a date/time field named StartDateTime to record when the employee clocks on, and another
named EndDateTime for when the employee clocks off. To calculate the time worked, create a query into this
table, and type this into the Field row of the query design grid:

Minutes: DateDiff("n", [StartDateTime], [EndDateTime])

Minutes is the alias for the calculated field; you could use any name you like. You must use "n" for DateDiff()
to return minutes: "m" returns months.
To display this value as hours and minutes on your report, use a text box with this Control Source:

=[Minutes] \ 60 & Format([Minutes] Mod 60, "\:00")


This formula uses:
the integer division operator (\) rather than regular division (/), for whole hours only;
the Mod operator to get the left over minutes after dividing by 60;
the Format() function to display the minutes as two digits with a literal colon.
Do not use the formula directly in the query if you wish to sum the time; the value it generates is just a piece
of text.

If you need to calculate a difference in seconds, use "s":

Seconds: DateDiff("s", [StartDateTime], [EndDateTime])


You can work in seconds for durations up to 67 years.
If you need to calculate the amount of pay due to the employee based on an HourlyRate field, use something
like this:

PayAmount: Round(CCur(Nz(DateDiff("n", [StartDateTime],


[EndDateTime]) * [HourlyRate] / 60, 0)), 2)

A MORE COMPLETE DATEDIFF FUNCTION


The following is a function I helped Graham Seach develop. As it states, it lets you calculate a
"precise" difference between two date/time values.
You specify how you want the difference between two date/times to be calculated by providing which
of ymwdhns (for years, months, weeks, days, hours, minutes and seconds) you want calculated.
For example:

?Diff2Dates("y", #06/01/1998#, #06/26/2002#)


4 years
?Diff2Dates("ymd", #06/01/1998#, #06/26/2002#)
4 years 25 days
?Diff2Dates("ymd", #06/01/1998#, #06/26/2002#, True)
4 years 0 months 25 days
?Diff2Dates("ymwd", #06/01/1998#, #06/26/2002#, True)
4 years 0 months 3 weeks 4 days
?Diff2Dates("d", #06/01/1998#, #06/26/2002#)
1486 days

?Diff2Dates("h", #01/25/2002 01:23:01#, #01/26/2002 20:10:34#)


42 hours
?Diff2Dates("hns", #01/25/2002 01:23:01#, #01/26/2002 20:10:34#)
42 hours 47 minutes 33 seconds
?Diff2Dates("dhns", #01/25/2002 01:23:01#, #01/26/2002 20:10:34#)
1 day 18 hours 47 minutes 33 seconds

?Diff2Dates("ymd",#12/31/1999#,#1/1/2000#)
1 day
?Diff2Dates("ymd",#1/1/2000#,#12/31/1999#)
-1 day

?Diff2Dates("ymd",#1/1/2000#,#1/2/2000#)
1 day
Special thanks to Mike Preston for pointing out an error in how it presented values when Date1 is
before Date2.
Updated 2012-08-07 as the results of a request in UtterAccess. Please note that this addition has not
been as thoroughly tested as usual. Please let me know if you have any problems with it!

'***************** Code Start **************


Public Function Diff2Dates(Interval As String, Date1 As Variant,
Date2 As Variant, _
Optional ShowZero As Boolean = False) As Variant
'Author:

? Copyright 2001 Pacific Database Pty Limited

'

Graham R Seach MCP MVP gseach@pacificdb.com.au

'

Phone: +61 2 9872 9594

'

This code is freeware. Enjoy...

'

(*) Amendments suggested by Douglas J. Steele MVP

Fax: +61 2 9872 9593

'
'Description:

This function calculates the number of years,

'

months, days, hours, minutes and seconds between

'

two dates, as elapsed time.

'
'Inputs:

Interval:

Intervals to be displayed (a string)

'

Date1:

The lower date (see below)

'

Date2:

The higher date (see below)

'

ShowZero:

Boolean to select showing zero elements

'
'Outputs:

On error: Null

'

On no error: Variant containing the number of years,

'

months, days, hours, minutes & seconds between

'

the two dates, depending on the display interval

'

selected.

'

If Date1 is greater than Date2, the result will

'

be a negative value.

'
intervals

The function compensates for the lack of any

'
but

not listed. For example, if Interval lists "m",

'

not "y", the function adds the value of the year

'

component to the month component.

'
it

If ShowZero is True, and an output element is zero,

'

is displayed. However, if ShowZero is False or

'

omitted, no zero-value elements are displayed.

'
"ym",

For example, with ShowZero = False, Interval =

'

elements = 0 & 1 respectively, the output string

'

will be "1 month" - not "0 years 1 month".

On Error GoTo Err_Diff2Dates

Dim booCalcYears As Boolean


Dim booCalcMonths As Boolean
Dim booCalcDays As Boolean
Dim booCalcHours As Boolean
Dim booCalcMinutes As Boolean
Dim booCalcSeconds As Boolean
Dim booCalcWeeks As Boolean
Dim booSwapped As Boolean
Dim dtTemp As Date
Dim intCounter As Integer

Dim lngDiffYears As Long


Dim lngDiffMonths As Long
Dim lngDiffDays As Long
Dim lngDiffHours As Long
Dim lngDiffMinutes As Long
Dim lngDiffSeconds As Long
Dim lngDiffWeeks As Long
Dim varTemp As Variant

Const INTERVALS As String = "dmyhnsw"

'Check that Interval contains only valid characters


Interval = LCase$(Interval)
For intCounter = 1 To Len(Interval)
If InStr(1, INTERVALS, Mid$(Interval, intCounter, 1)) = 0
Then
Exit Function
End If
Next intCounter

'Check that valid dates have been entered


If IsNull(Date1) Then Exit Function
If IsNull(Date2) Then Exit Function
If Not (IsDate(Date1)) Then Exit Function
If Not (IsDate(Date2)) Then Exit Function

'If necessary, swap the dates, to ensure that


'Date1 is lower than Date2
If Date1 > Date2 Then

dtTemp = Date1
Date1 = Date2
Date2 = dtTemp
booSwapped = True
End If

Diff2Dates = Null
varTemp = Null

'What intervals are supplied


booCalcYears = (InStr(1, Interval, "y") > 0)
booCalcMonths = (InStr(1, Interval, "m") > 0)
booCalcDays = (InStr(1, Interval, "d") > 0)
booCalcHours = (InStr(1, Interval, "h") > 0)
booCalcMinutes = (InStr(1, Interval, "n") > 0)
booCalcSeconds = (InStr(1, Interval, "s") > 0)
booCalcWeeks = (InStr(1, Interval, "w") > 0)

'Get the cumulative differences


If booCalcYears Then
lngDiffYears = Abs(DateDiff("yyyy", Date1, Date2)) - _
IIf(Format$(Date1, "mmddhhnnss") <= Format$(Date2,
"mmddhhnnss"), 0, 1)
Date1 = DateAdd("yyyy", lngDiffYears, Date1)
End If

If booCalcMonths Then
lngDiffMonths = Abs(DateDiff("m", Date1, Date2)) - _

IIf(Format$(Date1, "ddhhnnss") <= Format$(Date2,


"ddhhnnss"), 0, 1)
Date1 = DateAdd("m", lngDiffMonths, Date1)
End If

If booCalcWeeks Then
lngDiffWeeks = Abs(DateDiff("w", Date1, Date2)) - _
IIf(Format$(Date1, "hhnnss") <= Format$(Date2,
"hhnnss"), 0, 1)
Date1 = DateAdd("ww", lngDiffWeeks, Date1)
End If

If booCalcDays Then
lngDiffDays = Abs(DateDiff("d", Date1, Date2)) - _
IIf(Format$(Date1, "hhnnss") <= Format$(Date2,
"hhnnss"), 0, 1)
Date1 = DateAdd("d", lngDiffDays, Date1)
End If

If booCalcHours Then
lngDiffHours = Abs(DateDiff("h", Date1, Date2)) - _
IIf(Format$(Date1, "nnss") <= Format$(Date2,
"nnss"), 0, 1)
Date1 = DateAdd("h", lngDiffHours, Date1)
End If

If booCalcMinutes Then
lngDiffMinutes = Abs(DateDiff("n", Date1, Date2)) - _
IIf(Format$(Date1, "ss") <= Format$(Date2, "ss"),
0, 1)
Date1 = DateAdd("n", lngDiffMinutes, Date1)

End If

If booCalcSeconds Then
lngDiffSeconds = Abs(DateDiff("s", Date1, Date2))
Date1 = DateAdd("s", lngDiffSeconds, Date1)
End If

If booCalcYears And (lngDiffYears > 0 Or ShowZero) Then


varTemp = lngDiffYears & IIf(lngDiffYears <> 1, " years",
" year")
End If

If booCalcMonths And (lngDiffMonths > 0 Or ShowZero) Then


If booCalcMonths Then
varTemp = varTemp & IIf(IsNull(varTemp), Null, " ") & _
lngDiffMonths & IIf(lngDiffMonths <> 1, "
months", " month")
End If
End If

If booCalcWeeks And (lngDiffWeeks > 0 Or ShowZero) Then


If booCalcWeeks Then
varTemp = varTemp & IIf(IsNull(varTemp), Null, " ") & _
lngDiffWeeks & IIf(lngDiffWeeks <> 1, "
weeks", " week")
End If
End If

If booCalcDays And (lngDiffDays > 0 Or ShowZero) Then


If booCalcDays Then

varTemp = varTemp & IIf(IsNull(varTemp), Null, " ") & _


lngDiffDays & IIf(lngDiffDays <> 1, " days",
" day")
End If
End If

If booCalcHours And (lngDiffHours > 0 Or ShowZero) Then


If booCalcHours Then
varTemp = varTemp & IIf(IsNull(varTemp), Null, " ") & _
lngDiffHours & IIf(lngDiffHours <> 1, "
hours", " hour")
End If
End If

If booCalcMinutes And (lngDiffMinutes > 0 Or ShowZero) Then


If booCalcMinutes Then
varTemp = varTemp & IIf(IsNull(varTemp), Null, " ") & _
lngDiffMinutes & IIf(lngDiffMinutes <> 1, "
minutes", " minute")
End If
End If

If booCalcSeconds And (lngDiffSeconds > 0 Or ShowZero) Then


If booCalcSeconds Then
varTemp = varTemp & IIf(IsNull(varTemp), Null, " ") & _
lngDiffSeconds & IIf(lngDiffSeconds <> 1, "
seconds", " second")
End If
End If

If booSwapped Then
varTemp = "-" & varTemp
End If

Diff2Dates = Trim$(varTemp)

End_Diff2Dates:
Exit Function

Err_Diff2Dates:
Resume End_Diff2Dates

End Function
'************** Code End *****************

CONSTRUCTING MODERN TIME ELAPSED STRINGS IN


ACCESS
Office 2007
This content is outdated and is no longer being maintained. It is provided as a courtesy for individuals
who are still using these technologies. This page may contain URLs that were valid when originally
published, but now link to sites or pages that no longer exist.
Summary: Learn how to use Microsoft Office Access 2007 to display the time elapsed between the
current date and another date. (5 printed pages)
Kerry Westphal, Microsoft Corporation
March 2009
Applies to: 2007 Microsoft Office system, Microsoft Office Access 2007

Overview
Many Web 2.0 applications are designed to make it easy to vizualize complex data. I found myself
recently challenged with this task while working on a project where I wanted to display on a report to
show the time elapsed between the current date and another date. Some example scenarios could
include how much time has elapsed since a user profile was updated, the time that remains until taxes
are due, or how long a library book was checked out. I did not merely want to show the hours or even
days elapsed, but something more in sync with the way I want the information given to
mespecifically, that when dates are closer to the current date and time that they are represented
exactly, and dates and times that are farther away are shown generally. I wrote the ElapsedTime userdefined function to perform this task. The function can be used in a query to obtain a string that
represents the time elapsed. The string returned is either specific or general depending on the length
of time elapsed. For example, if the date is close to the current date, it appears as "In 12 hours, 27
minutes". If the date was long ago, it appears as, "A year ago". The following screen shot shows the
results of the ElapsedTime function when it is used to track items in a calendar.
Figure 1. Report showing modern elapsed time string

How It Works
The ElapsedTime function does the work. Call ElapsedTime from a form, report, or query to get a
string that shows the time elapsed between the date that you pass the function and the current date.
Pass ElapsedTime a date/time value as its only argument and the rest is completed for you.

Public Function ElapsedTime(dateTimeStart As Date) As String


'*************************************************************
' Function ElapsedTime(dateTimeStart As Date) As String
' Returns the time elapsed from today in a display string like,
' "In 12 hours, 41 minutes"
'*************************************************************
On Error GoTo ElapsedTime_Error
Dim result As String
Dim years As Double
Dim month As Double
Dim days As Double
Dim weeks As Double
Dim hours As Double
Dim minutes As Double

If IsNull(dateTimeStart) = True Then Exit Function

years = DateDiff("yyyy", Now(), dateTimeStart)


month = DateDiff("m", Now(), dateTimeStart)
days = DateDiff("d", Now(), dateTimeStart)
weeks = DateDiff("ww", Now(), dateTimeStart)
hours = DateDiff("h", Now(), dateTimeStart)
minutes = DateDiff("n", Now(), dateTimeStart)

Select Case years


Case Is = 1
result = "Next year"
Case Is > 1
result = "In " & years & " years"
Case Is = -1
result = "Last Year"
Case Is < -1
result = Abs(years) & " years ago"
End Select

Select Case month


Case 2 To 11
result = "In " & month & " months"
Case Is = 1
result = "This month"
Case Is = -1
result = "Last month"
Case -11 To -2
result = Abs(month) & " months ago"
End Select

Select Case days


Case 2 To 6
result = "In " & days & " days"
Case Is = 1
result = "Tomorrow"
Case Is = -1
result = "Yesterday"

Case -6 To -2
result = Abs(days) & " days ago"
End Select

Select Case weeks


Case 2 To 5
result = "In " & weeks & " weeks"
Case Is = 1
result = "Next week"
Case Is = -1
result = "Last week"
Case -5 To -2
result = Abs(weeks) & " weeks ago"
End Select

Select Case hours


Case Is = 1
Select Case minutes - (Int(minutes / 60) * 60)
Case Is = 0
result = "In an hour"
Case Is = 1
result = "In an hour and one minute"
Case Is = -1
result = "In an hour and one minute"
Case 2 To 59
result = "In an hour and " & _
minutes - (Int(minutes / 60) * 60) & "
minutes"
Case 60

result = "In an hour"


Case -59 To -2
result = "In an hour and " & _
minutes - (Int(minutes / 60) * 60) & "
minutes"
Case -60
result = "In an hour"
End Select
Case 2 To 23
Select Case minutes - (Int(minutes / 60) * 60)
Case Is = 1
result = "In " & Int(minutes / 60) & _
" hours and one minute"
Case Is = 0
result = "In " & Int(minutes / 60) & "
hours"
Case 2 To 59
result = "In " & Int(minutes / 60) & "
hours, " & _
minutes - (Int(minutes / 60) * 60) & "
minutes"
Case Is = -1
result = "In " & Int(minutes / 60) & _
" hours and one minute"
Case -59 To -2
result = "In " & Int(minutes / 60) & "
hours, " & _
minutes - (Int(minutes / 60) * 60) & "
minutes"
Case Is = 60
result = "In " & Int(minutes / 60) & "
hours"

Case Is = -60
result = "In " & Int(minutes / 60) & "
hours"
End Select
Case Is = -1
Select Case (Int(minutes / 60) * 60) - minutes + 60
Case Is = 0
result = "An hour ago"
Case Is = 1
result = "An hour and 1 minute ago"
Case 2 To 59
result = "An hour ago and " & _
(Int(minutes / 60) * 60) - minutes + 60
& _
" minutes ago"
Case 60
result = "An hour ago"
Case Is = -1
result = "An hour and 1 minute ago"
Case -59 To -2
result = "An hour ago and " & _
(Int(minutes / 60) * 60) - minutes + 60
& _
" minutes ago"
Case -60
result = "An hour ago"
End Select
Case -23 To -2
Select Case (Int(minutes / 60) * 60) - minutes + 60
Case Is = 0

result = Abs(Int(minutes / 60) + 1) & "


hours ago"
Case Is = 1
result = Abs(Int(minutes / 60) + 1) & _
" hours and one minute ago"
Case 2 To 59
result = Abs(Int(minutes / 60) + 1) & "
hours, " _
& (Int(minutes / 60) * 60) - minutes + 60 &
_
" minutes ago"
Case 60
result = Abs(Int(minutes / 60)) & " hours
ago"
Case Is = -1
result = Abs(Int(minutes / 60) + 1) & _
" hours and one minute ago"
Case -59 To -2
result = Abs(Int(minutes / 60) + 1) & _
" hours, " & _
(Int(minutes / 60) * 60) - minutes + 60
& _
" minutes ago"
Case -60
result = Abs(Int(minutes / 60) + 1) & "
hours ago"
End Select
End Select

Select Case minutes


Case 2 To 59

result = "In " & minutes & " minutes "


Case Is = 1
result = "In 1 minute"
Case Is = 0
result = "Now"
Case Is = -1
result = "A minute ago"
Case -59 To -2
result = Abs(minutes) & " minutes ago"
End Select

ElapsedTime = result

ElapsedTime_Exit:
Exit Function

ElapsedTime_Error:
MsgBox "Error " & Err.Number & ": " & Err.Description, _
vbCritical, "ElapsedTime"
Resume ElapsedTime_Exit

End Function

QUOTATION MARKS WITHIN QUOTES


In Access, you use the double-quote character around literal text, such as the Control Source of a text box:
="This text is in quotes."
Often, you need quote marks inside quotes, e.g. when working with DLookup(). This article explains how.

Basics
You cannot just put quotes inside quotes like this:
="Here is a "word" in quotes"
Error!
Access reads as far as the quote before word, thinks that ends the string, and has no idea what to do with the
remaining characters.
The convention is to double-up the quote character if it is embedded in a string:
="Here is a ""word"" in quotes"
It looks a bit odd at the end of a string, as the doubled-up quote character and the closing quote appear as 3 in
a row:
="Here is a ""word"""
Summary:
Control Source
property

Result

Explanation

="This is literal text."

This is literal text.

Literal text
goes in quotes.

="Here is a "word" in
quotes"

Access thinks
the quote
finishes
before word,
and does not
know what to
do with the
remaining
characters.

="Here is a ""word""
in quotes"

Here is a "word" in quotes

You must
double-up the
quote
character
inside quotes.

Here is a "word"

The doubledup quotes


after word plus
the closing

="Here is a ""word"""

quote gives
you 3 in a row.

Expressions
Where this really matters is for expressions that involve quotes.
For example, in the Northwind database, you would look up the City in the Customers table where
the CompanyName is "La maison d'Asie":
=DLookup("City", "Customers", "CompanyName = ""La maison d'Asie""")
If you wanted to look up the city for the CompanyName in your form, you need to close the quote and
concatenate that name into the string:
=DLookup("City", "Customers", "CompanyName = """ & [CompanyName] &
"""")
The 3-in-a-row you already recognise. The 4-in-a-row gives you just a closing quote after the company name.
As literal text, it goes in quotes, which accounts for the opening and closing text. And what is in quotes is just
the quote character - which must be doubled up since it is in quotes.
As explained in the article on DLookup(), the quote delimiters apply only to Text type fields.
The single-quote character can be used in some contexts for quotes within quotes. However, we do not
recommend that approach: it fails as soon as a name contains an apostrophe (like the CompanyName example
above.)

WHY CAN'T I APPEND SOME RECORDS?


When you execute an append query, you may see a dialog giving reasons why some records were not inserted:

The dialog addresses four problem areas. This article explains each one, and how to solve them.

Type conversion failure


Access is having trouble putting the data into the fields because the field type does not match.
For example, if you have a Number or Date field, and the data you are importing contains:
Unknown
N/A
these are not valid numbers or dates, so produce a "type conversion" error.
In practice, Access has problems with any data that is is not in pure format. If the numbers have a Dollar sign at
the front or contain commas or spaces between the thousands, the import can fail. Similarly, dates that are
not in the standard US format are likely to fail.
Sometimes you can work around these issues by importing the data into a table that has all Text type fields,
and then typecasting the fields, using Val(), CVDate(), or reconstructing the dates with Left(), Mid(), Right(),
and DateSerial(). For more on typecasting, see Calculated fields misinterpreted.

Key violations
The primary key must have a unique value. If you try to import a record where the primary key value is 9, and
you already have a record where the primary key is 9, the import fails due to a violation of the primary key.
You can also violate a foreign key. For example, if you have a field that indicates which category a record
belongs to, you will have created a table of categories, and established a relationship so only valid categories
are allowed in this field. If the record you are importing has an invalid category, you have a violation of the
foreign key.
You may have other unique indexes in your table as well. For example, an enrolment table might have a
StudentID field (who is enrolled) and a ClassID field (what class they enrolled in), and you might create a
unique index on the combination of StudentID + ClassID so you cannot have the same student enrolled twice in
the one class. Now if the data you are importing has an existing combination of Student and Class, the import
will fail with a violation of this unique index.

Lock violations
Lock violations occur when the data you are trying to import is already in use.
To solve this issue, make sure no other users have this database open, and close all other tables, queries,
forms, and reports.
If the problem persists, Make sure you have set Default Record Locking to "No Locks" under File (Office
Button) | Options | Advanced (Access 2007 or later), or in earlier versions: Tools | Options | Advanced.

Validation rule violations


There are several places to look to solve for this one:
There is something in the Validation Rule of one of the fields, and the data you are trying to add does not
meet this rule. The Validation Rule of each field is in the lower pane of table design window.
There is something in the Validation Rule of the table, and the data you are trying to add does not meet this
rule. The Validation Rule of the table is in the Properties box.
The field has the Required property set to Yes, but the data has no value for that field.
The field has the Allow Zero Length property set to No (as it should), but the data contains zero-length-strings
instead of nulls.
If none of these apply, double-check the key violations above.

Still stuck?
If the problem data is not obvious, you might consider clicking Yes in the dialog shown at the beginning of this
article. Access will create a table named Paste Errors or Import Errors or similar. Examining the specific
records that failed should help to identify what went wrong.
After fixing the problems, you can then import the failed records, or restore a backup of the database and run
the complete import again.

ROUNDING IN ACCESS
To round numbers, Access 2000 and later has a Round() function built in.
For earlier versions, get this custom rounding function by Ken Getz.

The built-in function


Use the Round() function in the Control Source of a text box, or in a calculated query field.
Say you have this expression in the Field row in query design:
Tax: [Amount] * [TaxRate]
To round to the nearest cent, use:
Tax: Round([Amount] * [TaxRate], 2)

Rounding down
To round all fractional values down to the lower number, use Int():
Int([MyField])
All these numbers would then be rounded down to 2: 2.1, 2.5, 2.8, and 2.99.
To round down to the lower cent (e.g. $10.2199 becomes $10.21), multiply by 100, round, and then divide by
100:
Int(100 * [MyField]) / 100
Be aware of what happens when negative values are rounded down: Int(-2.1) yields -3, since that is the integer
below. To round towards zero, use Fix() instead of Int():
Fix(100 * [MyField]) / 100

Rounding up
To round upwards towards the next highest number, take advantage of the way Int() rounds negative
numbers downwards, like this:
- Int( - [MyField])
As shown above, Int(-2.1) rounds down to -3. Therefore this expression rounds 2.1 up to 3.
To round up to the higher cent, multiply by -100, round, and divide by -100:
Int(-100 * [MyField]) / -100

Round to nearest 5 cents


To round to the nearest 5 cents, multiply the number by 20, round it, and divide by 20:
Round(20 * [MyField], 0) / 20
Similarly, to round to the nearest quarter, multiply by 4, round, and divide by 4:
Round(4 * [MyField], 0) / 4

Round to $1000

The Round() function in Excel accepts negative numbers for the number of decimal places, e.g. Round(123456,
-3) rounds to the nearest 1000. Unfortunately, the Access function does not support this.
To round to the nearest $1000, divide by 1000, round, and multiply by 1000. Example:
1000 * Round([Amount] / 1000, 0)
To round down to the lower $1000, divide by 1000, get the integer value, and multiply by 1000. Example:
1000 * Int([Amount] / 1000)
To round up to the higher $1000, divide by 1000, negate before you get the integer value. Example:
-1000 * Int( [Amount] / -1000)
To round towards zero, use Fix() instead of Int().
Alternatively, Ken Getz' custom rounding function behaves like the Excel function.

Why round?
There is a Decimal Places property for fields in a table/query and for text boxes on a form/report. This
property only affects the way the field is displayed, not the way it is stored. The number will appear to be
rounded, but when you sum these numbers (e.g. at the foot of a report), the total may not add up correctly.
Round the field when you do the calculation, and the field will sum correctly.
This applies to currency fields as well. Access displays currency fields rounded to the nearest cent, but it stores
the value to the hundredth of a cent (4 decimal places.)

Bankers rounding
The Round() function in Access uses a bankers rounding. When the last significant digit is a 5, it rounds to the
nearest even number. So, 0.125 rounds to 0.12 (2 is even), whereas 0.135 rounds to 0.14 (4 is even.)
The core idea here is fairness: 1,2,3, and 4 get rounded down. 6,7,8, and 9 get rounded up. 0 does not need
rounding. So if the 5 were always rounded up, you would get biased results - 4 digits being rounded down, and
5 being rounded up. To avoid this, the odd one out (the 5) is rounded according to the previous digit, which
evens things up.
If you do not wish to use bankers rounding, get Ken Getz' custom function (linked above.)

Floating point errors


Fractional values in a computer are typically handled as floating point numbers. Access fields of type Double or
Single are this type. The Double gives about 15 digits of precision, and the Single gives around 8 digits (similar
to a hand-held calculator.)
But these numbers are approximations. Just as 1/3 requires an infinite number of places in the decimal
system, most floating point numbers cannot be represented precisely in the binary system. Wikipedia explains
the accuracy problems you face when computing floating point numbers.
The upshot is that marginal numbers may not round the way you expect, due to the fact that the actual values
and the display values are not the same. This is especially noticeable when testing bankers rounding.

One way to avoid these issues is to use a fixed point or scalar number instead. The Currency data type in
Access is fixed point: it always stores 4 decimal places.
For example, open the Immediate Window (Ctrl+G), and enter:
? Round(CCur(.545),2), Round(CDbl(.545),2)
The Currency type (first one) yields 0.54, whereas the Double yields 0.55. The Currency rounds correctly
(towards the even 4); the floating point type (Double) is inaccurate. Similarly, if you try 8.995, the Currency
correctly rounds up (towards the even 0), while the Double rounds it down (wrong.)
Currency copes with only 4 decimal places. Use the scalar type Decimal if you need more places after the
decimal point.

Rounding dates and times


Note that the Date/Time data type in Access is a special kind of floating point type, where the fractional part
represents the time of day. Consequently, Date/Time fields that have a time component are subject to floating
point errors as well.
The function below rounds a date/time value to the specified number of seconds. For example, to round to the
nearest half hour (30 * 60 seconds), use:
=RoundTime([MyDateTimeField], 1800)

Public Function RoundTime(varTime As Variant, Optional ByVal lngSeconds As Long = 900&) As


Variant

'Purpose:
seconds

Round a date/time value to the nearest number of

'Arguments: varTime = the date/time value


'

lngSeconds = number of seconds to round to.

'

e.g.

'

600 for nearest 10 minutes,

'

3600 for nearest hour,

'
'Return:
passed in.

60 for nearest minute,

86400 for nearest day.


Rounded date/time value, or Null if no date/time

'Note:

lngSeconds must be between 1 and 86400.

'

Default rounds is nearest 15 minutes.

Dim lngSecondsOffset As Long

RoundTime = Null

'Initialize to return Null.

If Not IsError(varTime) Then


If IsDate(varTime) Then
If (lngSeconds < 1&) Or (lngSeconds > 86400) Then
lngSeconds = 1&
End If
lngSecondsOffset = lngSeconds * CLng(DateDiff("s",
#12:00:00 AM#, TimeValue(varTime)) / lngSeconds)
RoundTime = DateAdd("s", lngSecondsOffset,
DateValue(varTime))
End If
End If
End Function

Duplicate the record in form and subform


The example below shows how to duplicate the record in the main form, and also the related records in the
subform.
Change the highlighted names to match the names of your fields, table, and subform control. To use the code
as is, add a command button to the Orders form in Northwind.

The code
Private Sub cmdDupe_Click()
'On Error GoTo Err_Handler
'Purpose:
Duplicate the main form record and related
records in the subform.
Dim strSql As String
Dim lngID As Long
record.

'SQL statement.
'Primary key value of the new

'Save any edits first


If Me.Dirty Then
Me.Dirty = False
End If

'Make sure there is a record to duplicate.


If Me.NewRecord Then
MsgBox "Select the record to duplicate."
Else
'Duplicate the main record: add to form's clone.
With Me.RecordsetClone
.AddNew
!CustomerID = Me.CustomerID
!EmployeeID = Me.EmployeeID
!OrderDate = Date
'etc for other fields.

.Update

'Save the primary key value, to use as the foreign


key for the related records.
.Bookmark = .LastModified
lngID = !OrderID

'Duplicate the related records: append query.


If Me.[Orders
Subform].Form.RecordsetClone.RecordCount > 0 Then
strSql = "INSERT INTO [Order Details] ( OrderID,
ProductID, Quantity, UnitPrice, Discount ) " & _
"SELECT " & lngID & " As NewID, ProductID,
Quantity, UnitPrice, Discount " & _
"FROM [Order Details] WHERE OrderID = " &
Me.OrderID & ";"
DBEngine(0)(0).Execute strSql, dbFailOnError
Else
MsgBox "Main record duplicated, but there were
no related records."
End If

'Display the new duplicate.


Me.Bookmark = .LastModified
End With
End If

Exit_Handler:
Exit Sub

Err_Handler:

MsgBox "Error " & Err.Number & " - " & Err.Description, ,
"cmdDupe_Click"
Resume Exit_Handler
End Sub

Explanation
The code first saves any edits in progress, and checks that the form is not at a new record.
The AddNew assigns a buffer for the new record. We then copy some sample fields from the current form into
this buffer, and save the new record with Update.
Ensuring the new record is current (by setting the recordset's bookmark to the last modified one), we store the
new primary key value in a variable, so we can use it in the related records.
Then, we check that there are records in the subform, and duplicate them with an append query statement.
The query selects the same child records shown in the subform, and appends them to the same table with the
new OrderID. If you are not sure how to create this query statement for your database, you can see an
example by mocking up a query and switching to SQL view (View menu, in query design.)
So why did we use AddNew in the main form, but an append query statement to duplicate the subform
records?
AddNew gives us the new primary key value, which we needed to create the related records.
The append query creates all related records in one step.
We are able to move to the new record in the main form without having to Requery.

ASSIGN DEFAULT VALUES FROM THE LAST RECORD


Sometimes you need to design a form where many fields will have similar values to the last record entered,
so you can expedite data entry if all controls carry data over. There are two ways to achieve this:
Set the Default Value of each control so they offer the same value as soon as you move into the new record.
Use the BeforeInsert event of the form so they all inherit the same values as soon as the user starts typing in
the new record.
The first is best suited to setting a particular field. Dev Ashish explains the process here: Carry current value
of a control to new records.
This article takes the second approach, which has these advantages:
Since the new record is blank until the first keystroke, the user is not confused about whether this is a new or
existing record.
Values are inserted even for the first entry after the form is opened (assuming there are records.)
The code is generic (does not need to refer to each control by name), so can be reused for any form.
The default value is not applied to the control that the user is trying to type into when they start the new
record.
Note: The code works with Access 2007 and later if the form does not contain controls bound to multi-valued
fields (including Attachment.)

The steps
To implement this tip in your form:
Open a new module.
In Access 95 - 2003, click the Modules tab of the Database window and click New.
In Access 2007 and later, click the Create ribbon, drop-down the right-most icon in the Other group and
choose Module.
Copy the code below, and paste into the new module.
Verify that Access understands the code by choosing Compile from the Debug menu.
Save it with a name such as Module1. Close the code window.
Open your form in design view.
Open the Properties sheet, making sure you are looking at the properties of the Form (not those of a text
box.)
On the Event tab of the Properties box, set the Before Insert property to:
[Event Procedure]
Click the Build button (...) beside this Property. Access opens the code window.
Set up the code like this:

Private Sub Form_BeforeInsert(Cancel As Integer)


Dim strMsg As String
Call CarryOver(Me, strMsg)
If strMsg <> vbNullString Then
MsgBox strMsg, vbInformation
End If
End Sub
Save.
Repeat steps 5 - 9 for any other forms.
If there are specific fields you do not wish to carry over, add the name of the controls in quotes inside the
brackets, with commas between them. For example to leave the Notes and EmployeeID fields blank, use:
Call CarryOver(Me, strMsg, "Notes", "EmployeeID")
The code is intelligent enough not to try to duplicate your AutoNumber or calculated fields, so you do not need
to explicitly exclude those. Similarly, if the form is a subform, any fields named in LinkChildFields will be the
same as the record we are copying from, so you do not need to explicitly exclude those either.
If you do not wish to see any error messages, you could just set the Before Insert property of the form to:
=CarryOver([Form], "")

The code
Here is the code for the generic module (Step 2 above.)

Public Function CarryOver(frm As Form, strErrMsg As String, ParamArray avarExceptionList()) As


Long

On Error GoTo Err_Handler


'Purpose: Carry over the same fields to a new record, based
on the last record in the form.
'Arguments: frm

= the form to copy the values

on.
'
messages to.

strErrMsg

= string to append error

'
avarExceptionList = list of control names NOT to
copy values over to.
'Return:

Count of controls that had a value assigned.

'Usage:
In a form's BeforeInsert event, excluding
Surname and City controls:

'

Call CarryOver(Me, strMsg, "Surname", City")

Dim rs As DAO.Recordset

'Clone of form.

Dim ctl As Control

'Each control on form.

Dim strForm As String


handler.)
Dim strControl As String

'Name of form (for error

'Each control in the loop

Dim strActiveControl As String 'Name of the active control.


Don't assign this as user is typing in it.
Dim strControlSource As String

'ControlSource property.

Dim lngI As Long

'Loop counter.

Dim lngLBound As Long


list array.

'Lower bound of exception

Dim lngUBound As Long


list array.

'Upper bound of exception

Dim bCancel As Boolean


operation.

'Flag to cancel this

Dim bSkip As Boolean

'Flag to skip one control.

Dim lngKt As Long

'Count of controls assigned.

'Initialize.
strForm = frm.Name
strActiveControl = frm.ActiveControl.Name
lngLBound = LBound(avarExceptionList)
lngUBound = UBound(avarExceptionList)

'Must not assign values to the form's controls if it is not


at a new record.
If Not frm.NewRecord Then
bCancel = True
strErrMsg = strErrMsg & "Cannot carry values over. Form
'" & strForm & "' is not at a new record." & vbCrLf
End If

'Find the record to copy, checking there is one.


If Not bCancel Then
Set rs = frm.RecordsetClone
If rs.RecordCount <= 0& Then
bCancel = True
strErrMsg = strErrMsg & "Cannot carry values over.
Form '" & strForm & "' has no records." & vbCrLf
End If
End If

If Not bCancel Then


'The last record in the form is the one to copy.
rs.MoveLast
'Loop the controls.
For Each ctl In frm.Controls
bSkip = False
strControl = ctl.Name
'Ignore the active control, those without a
ControlSource, and those in the exception list.
If (strControl <> strActiveControl) And
HasProperty(ctl, "ControlSource") Then
For lngI = lngLBound To lngUBound
If avarExceptionList(lngI) = strControl Then
bSkip = True
Exit For
End If
Next
If Not bSkip Then
'Examine what this control is bound to.
Ignore unbound, or bound to an expression.

strControlSource = ctl.ControlSource
If (strControlSource <> vbNullString) And
Not (strControlSource Like "=*") Then
'Ignore calculated fields (no
SourceTable), autonumber fields, and null values.
With rs(strControlSource)
If (.SourceTable <> vbNullString)
And ((.Attributes And dbAutoIncrField) = 0&) _
And Not
(IsCalcTableField(rs(strControlSource)) Or IsNull(.Value)) Then
If ctl.Value = .Value Then
'do nothing. (Skipping this
can cause Error 3331.)
Else
ctl.Value = .Value
lngKt = lngKt + 1&
End If
End If
End With
End If
End If
End If
Next
End If

CarryOver = lngKt

Exit_Handler:
Set rs = Nothing
Exit Function

Err_Handler:
strErrMsg = strErrMsg & Err.Description & vbCrLf
Resume Exit_Handler
End Function

Private Function IsCalcTableField(fld As DAO.Field) As Boolean


'Purpose: Returns True if fld is a calculated field (Access
2010 and later only.)
On Error GoTo ExitHandler
Dim strExpr As String

strExpr = fld.Properties("Expression")
If strExpr <> vbNullString Then
IsCalcTableField = True
End If

ExitHandler:
End Function

Public Function HasProperty(obj As Object, strPropName As


String) As Boolean
'Purpose: Return true if the object has the property.
Dim varDummy As Variant

On Error Resume Next


varDummy = obj.Properties(strPropName)
HasProperty = (Err.Number = 0)
End Function

How it works
You can use the code without understanding how it works, but the point of this website is help you understand
how to use Access.

The arguments
The code goes in a general module, so it can be used with any form. Passing in the form as an argument allows
the code to do anything that you could with with Me in the form's own module.
The second argument is a string that this routine can append any error messages to. Since the function does
not pop up any error messages, the calling routine can then decide whether it wants to display the errors,
ignore them, pass them to a higher level function, or whatever. I find this approach very useful for generic
procedures, especially where they can be called in various ways.
The final argument accepts an array, so the user can type as many literals as they wish, separated by commas.
The ParamArray keyword means any number of arguments to be passed in. They arrive as a variant array, so
the first thing the function does is to use LBound() to get the lower array bound (usually zero) and UBound() to
get the upper array bound - standard array techniques.

The checks
The code checks that the form is at a new record (which also verifies it is a bound form). Then it checks that
there is a previous record to copy, and moves the form's RecordsetClone to the last record - the one we want
to copy the field values from.
It then loops through all the controls on the form. The control's Name can be different from its ControlSource,
so it is the ControlSource we must match to the field in the RecordsetClone. Some controls (labels, lines, ...)
have no ControlSource. Others may be unbound, or bound to an expression, or bound to a calculated query
field, or bound to an AutoNumber field - all cases where no assignment can be made. The code tests for these
cases like this:
Control

Action

Controls with no ControlSource (command buttons,


labels, ...)

The HasProperty() function tests for this property,


recovers from any error, and informs the main routine
whether to skip the control.

The control the user is typing into (so we do not


overwrite the entry)

Compare the control's Name with


Screen.ActiveControl.Name.

Controls named in the exception list

Compare the control's Name with names in the


exception list array.

Unbound controls

Test if the ControlSource property is a zero-length


string.

Controls bound to an expression (cannot be assigned


a value)

Test if the ControlSource starts with "=".

Controls bound to a calculated query field

In the form's RecordsetClone, the Field has


a SourceTable property. For fields created in the
query, this property is is a zero-length string.

Controls bound to a calculated table field

In the form's RecordsetClone, the Field has an


Expression property that is not just a zero-length
string.

Controls bound to an AutoNumber field

In the form's RecordsetClone, the Attributes property


of the Field will have thedbAutoIncrField bit set.

Fields that were Null in the record we are copying


from

We bypass these, so Access can still apply any


DefaultValue.

If the control has not been culled along the way, we assign it the Value of the field in the form's
RecordsetClone, and increment our counter.

The return value


Finally, the function returns the number of controls that were assigned a value, in case the calling routine
wants to know.
If an error occurs, we return information about the error in the second argument, so the calling routine can
examine or display the error message to the user.

MANAGING MULTIPLE INSTANCES OF A FORM


Want to compare two or more clients on screen at the same time? Though rarely used, the feature was
introduced in Access 97. The New keyword creates an instance of a form with a couple of lines of code,
but managing the various instances takes a little more effort. A sample database demonstrates the code in
this article.

Creating Instances
A simple but inadequate approach is to place a command button on the form itself. For a form
named frmClient with a command button named cmdNewInstance, you need just 5 lines of code in the forms
module:

Dim frmMulti As Form


Private Sub cmdNewInstance_Click()
Set frmMulti = New Form_frmClient
frmMulti.SetFocus
End Sub

Open the form and click the command button. A second client form opens on top of the first, and can display a
different client. The second instance also has the command button, so you can open a third instance, and so
on.
However, these forms are not independent of each other. Close the first one, and they all close. Click the New
Instance button on the second one, and the third and fourth instances are replaced. Since the object
variable frmMulti is declared in the class module of the form, each instance can support only one subsequent
instance, so closing a form or reassigning this variable destroys all subsequent instances that may be open.
You also have difficulties keeping track of an instance. The Forms collection will have multiple entries with the
same name so Forms.frmClient is inadequate. The index number of theForms collection such
as Forms(3) wont work either: these numbers change as forms are opened and closed.

Managing Instances
To solve the dependencies, create a collection in another module. Add to the collection as each new instance
is opened, and remove from the collection when it is closed. Each instance is now completely independent of
the others, depending only on your collection for survival.
To solve the problem of the instances identity, use its hWnd the unique handle assigned to each window by
the operating system. This value should be constant for the life of the window, though the Access 97 Help File
warns: Caution: Because the value of this property can change while a program is running, don't store the
hWnd property value in a public variable. Presumably, this comment refers to reusing this value when a form

may be closed and reopened. The following example uses the hWnd of the instance as the key value in the
collection.
The first line below creates the collection where we can store independent instances of our form. The
function OpenAClient() opens an instance and appends it to our collection. This code is in
the basPublic module of the sample database:

Public clnClient As New Collection

'Instances of frmClient.

Function OpenAClient()
'Purpose:

Open an independent instance of form frmClient.

Dim frm As Form

'Open a new instance, show it, and set a caption.


Set frm = New Form_frmClient
frm.Visible = True
frm.Caption = frm.Hwnd & ", opened " & Now()

'Append it to our collection.


clnClient.Add Item:=frm, Key:=CStr(frm.Hwnd)

Set frm = Nothing


End Function

Function CloseAllClients()
'Purpose: Close all instances in the clnClient collection.
'Note: Leaves the copy opened directly from database
window/nav pane.
Dim lngKt As Long
Dim lngI As Long

lngKt = clnClient.Count

For lngI = 1 To lngKt


clnClient.Remove 1
Next
End Function

The second function CloseAllClients() demonstrates how to close these instances by removing them from our
collection. But if the user closes an instance with the normal interface, we need to remove that instance from
our collection. Thats done in the Close event of form frmClient like this:

Private Sub Form_Close()


'Purpose: Remove this instance from clnClient collection.
Dim obj As Object

Dim blnRemove As Boolean

'Object in clnClient

'Flag to remove it.

'Check if this instance is in the collection.


For Each obj In clnClient
If obj.Hwnd = Me.Hwnd Then
blnRemove = True
Exit For
End If
Next

'Deassign the object and remove from collection.


Set obj = Nothing
If blnRemove Then
clnClient.Remove CStr(Me.Hwnd)
End If
End Sub

ROLLING DATES BY PRESSING "+" OR "-"


Some commercial programs (Tracker, Quicken, etc) allow the user to press "+" or "-" to increment or
decrement a date without the hassle of selecting the day part of the field and entering a new value. This is
especially useful in fields where the default date offered could be a day or two different from the date desired.
To provide this functionality in Access, attach this Event Procedure to the KeyPress event of your control.

Select Case KeyAscii


Case 43

' Plus key

KeyAscii = 0
Screen.ActiveControl = Screen.ActiveControl + 1
Case 45

' Minus key

KeyAscii = 0
Screen.ActiveControl = Screen.ActiveControl - 1
End Select
When any alphanumeric key is pressed, Access passes its ASCII value to your event procedure in the variable
KeyAscii. The code examines this value, and acts only if the value is 43 (plus) or 45 (minus). It destroys the
keystroke (so it is not displayed) by setting the value of KeyAscii to zero. The active control is then incremented
or decremented.
This idea is not limited to dates, or even textboxes. The code can be adapted for other keystrokes as required.
Use the KeyDown event to distinguish between the two plus keys (top row and numeric keypad), or to trap
control keys such as {Esc}. Anyone feel like reprogramming an entire keyboard?

RETURN TO THE SAME RECORD NEXT TIME FORM IS


OPENED
When a form is opened, you may like to automatically load the most recently edited record. To do so:
Create a table to save the record's Primary Key value between sessions;
Use the form's Unload event to save the current record's ID;
Use the form's Load event to find that record again.
As an example, take a form that has CustomerID as the primary key field.

1. Create a table to save the Primary Key value between sessions


Create a table with these 3 fields:

Field Name Type


Variable
Value

Description

Text, 20 Holds the variable name. Mark as primary key.


Text, 80 Holds the value to be returned.

Description Text, 255 What this variable is used for/by.


Save this table with the name "tblSys". You may care to mark this as a hidden table.

2. Use the form's UnLoad event to save the record's ID.


Set the form's On Unload property to [Event Procedure], and add the following code. It finds (or
creates) a record in tblSys where the field Variable contains "CustomerIDLast", and stores the current
CustomerID in the field called Value.

Sub Form_Unload (Cancel As Integer)


Dim rs As DAO.Recordset

If Not IsNull(Me.CustomerID) Then


Set rs = CurrentDb().OpenRecordset("tblSys",
dbOpenDynaset)
With rs
.FindFirst "[Variable] = 'CustomerIDLast'"
If .NoMatch Then
.AddNew

'Create the entry if not found.

![Variable] = "CustomerIDLast"
![Value] = Me.CustomerID
![Description] = "Last customerID, for form
" & Me.Name
.Update
Else
.Edit

'Save the current record's

primary key.
![Value] = Me.CustomerID
.Update
End If
End With
rs.Close
End If
Set rs = Nothing
End Sub

3. Use the form's Load event to find that record again.


Set the form's On Load property to [Event Procedure], and add the following code. It performs these
steps:
locates the record in tblSys where the Variable field contains "CustomerIDLast";
gets the last stored CustomerID from the Value field;
finds that CustomerID in the form's clone recordset;
moves to that record by setting the form's Bookmark.

Sub Form_Load()
Dim varID As Variant
Dim strDelim As String
'Note: If CustomerID field is a Text field (not a Number
field), remove single quote at start of next line.

'strDelim = """"

varID = DLookup("Value", "tblSys", "[Variable] =


'CustomerIDLast'")
If IsNumeric(varID) Then
With Me.RecordsetClone
.FindFirst "[CustomerID] = " & strDelim & varID &
strDelim
If Not .NoMatch Then
Me.Bookmark = .Bookmark
End If
End With
End If
End Sub

UNBOUND TEXT BOX: LIMITING ENTRY LENGTH


Access automatically prevents you entering too much text if a control is bound to a field. Unbound controls
can be limited with the Input Mask - one "C" for each possible character. However, the input mask has side
effects such as appending underscores to the Text property and making it difficult to insert text into the
middle of an entry.
For a cleaner result, use a combination of the control's KeyPress and Change events.
Here's how.
Paste the two "subs" from the end of this article into a module. Save.
Call LimitKeyPress() in your text box's KeyPress event. For example, to limit a control named "City" to 40
characters, its KeyPress event procedure is:
Call LimitKeyPress(Me.City, 40, KeyAscii)
Call LimitChange() in your text box's Change event. For the same example, the Change event procedure is:
Call LimitChange(Me.City, 40)
Repeat steps 2 and 3 for other unbound text/combo boxes in your application.

Why Both Events?


When the Change event fires, the text is already in the control. There is no way to retrieve the last acceptable
entry if it's too long. You could create a variable for each control, store its last known good entry, and restore
that value if the Change event finds the text is too long. However, maintaining such a variable would be a
nightmare: can you guarantee to initialize every variable to the control's DefaultValue in the form's Load
event, update its variable on every occasion that a control is written to programmatically, effectively
document this to ensure no other programmer writes to the control without updating the variable, etc.?
The KeyPress event does not have these problems. You can simply discard unacceptable keystrokes, leaving
the text in the control as it was. However, this event alone is inadequate: a user can paste text into the control
without firing the KeyPress, KeyDown, or KeyUp events.
We need both events. Block unacceptable keystrokes in the KeyPress event before they reach the
control, and truncate entries in the Change event if the user pastes in too much text.

How Do These Procedures Work?


LimitKeyPress()
When a user types a character into the control, KeyPress is triggered. The value of KeyAscii tells you the
character typed. Setting KeyAscii to zero destroys the keystroke before it reaches the text box. The
middle line of this procedure does this, after checking two conditions.
The first "If ..." reads the number of characters already in the text box (its Text property). If any characters are
selected, they are replaced when a character is typed, so we subtract the length of the selection (its SelLength

property). If Access happens to be in Over-Type mode and the cursor is in the middle of the text, a character is
automatically selected so over-type still works.
Non-text keystrokes (such as Tab, Enter, PgDn, Home, Del, Alt, Esc) do not trigger the KeyPress event. The
KeyDown and KeyUp events let you manage those. However, BackSpace does trigger KeyPress. The second "If
..." block allows BackSpace to be processed normally.

LimitChange()
This procedure cleans up the case where the user changes the text in the control without firing the KeyPress
event, such as by pasting. It compares the length of the text in the control (ctl.Text) to the maximum
allowed ( iMaxLen). If it is too great, the procedure does 3 things: it notifies the user
(MsgBox), truncates the text (Left()), and moves the cursor to the end of the text (SelStart).

The Code
Paste these into a module. If you do not wish to use the LogError() function, replace the third last line of both
procedures with:

MsgBox "Error " & Err.Number & ": " & Err.Description

Sub LimitKeyPress(ctl As Control, iMaxLen As Integer, KeyAscii


As Integer)
On Error GoTo Err_LimitKeyPress
' Purpose:

Limit the text in an unbound text box/combo.

' Usage:

In the control's KeyPress event procedure:

'

Call LimitKeyPress(Me.MyTextBox, 12, KeyAscii)

' Note:
also.

Requires LimitChange() in control's Change event

If Len(ctl.Text) - ctl.SelLength >= iMaxLen Then


If KeyAscii <> vbKeyBack Then
KeyAscii = 0
Beep
End If
End If

Exit_LimitKeyPress:
Exit Sub

Err_LimitKeyPress:
Call LogError(Err.Number, Err.Description,
"LimitKeyPress()")
Resume Exit_LimitKeyPress
End Sub

Sub LimitChange(ctl As Control, iMaxLen As Integer)


On Error GoTo Err_LimitChange
' Purpose:

Limit the text in an unbound text box/combo.

' Usage:

In the control's Change event procedure:

'

Call LimitChange(Me.MyTextBox, 12)

' Note:
event also.

Requires LimitKeyPress() in control's KeyPress

If Len(ctl.Text) > iMaxLen Then


MsgBox "Truncated to " & iMaxLen & " characters.",
vbExclamation, "Too long"
ctl.Text = Left(ctl.Text, iMaxLen)
ctl.SelStart = iMaxLen
End If

Exit_LimitChange:
Exit Sub

Err_LimitChange:
Call LogError(Err.Number, Err.Description, "LimitChange()")
Resume Exit_LimitChange

End Sub

PROPERTIES AT RUNTIME: FORMS


In Access version 1, only a few properties such as Visible and Enabled were editable when the application was
running. In later versions, only a handful of properties are not available at runtime, so you can use
the Current event of a form or the Format event of a report section to conditionally alter the formatting and
layout of the data, depending on its content.
For example, to highlight those who owe you large amounts of money, you could add this in a form's Current
event:

With Me.AmountDue
If .Value > 500 Then
.Forecolor = 255
.Fontbold = True
.Fontsize = 14
.Height = 400
Else
.Forecolor = 0
.Fontbold = False
.Fontsize = 10
.Height = 300
End If
End With

Some of the changes you can perform are rather radical, such as changing the record source of a form while it
is running! You might do this to change the sort order, or - with astute use of SQL - to reduce network
traffic from a remote server.
In addition, some controls have properties which do not appear in the "Properties" list at all, since they
are available only at runtime. For example, combo boxes have a "column()" property which refers to the data
in the columns of the control. Picture a combo box called cboClient with 3 columns: ID, Surname, Firstname.
When not dropped down, only the ID is visible, so you decide to be helpful and add a read-only textbox
displaying the name. DLookup() will work, but it is much more efficient to reference the data already in the
combo bybinding your textbox to the expression:

= [cboClient].[Column](2) & " " & [cboClient].[Column](1)

HIGHLIGHT THE REQUIRED FIELDS, OR THE CONTROL


THAT HAS FOCUS

Would you like your forms to automatically identify the fields where an entry is required?
How about highlighting the control that has the focus, so you don't have to search for the cursor?
This utility automatically does both in any form in Form view (not Continuous), just by setting a property.
In the screenshot (right), Title is highlighted as the current field (yellow), and the name fields are required (red
background, and bold label with a star.) Modify the colors to whatever style suits you.

Implementation
To use this in your database:
Download the example database (24 kb zipped, for Access 2000 or later.)
Copy the module named ajbHighlight into your database.
Widen the labels attached to your controls (to handle the star and bolding.)
Set the On Load property of your form to:
=SetupForm([Form])
Do not substitute the name of your form in the expression above, i.e. use the literal [Form] as shown.

Options
To highlight the required fields only, use:
=SetupForm([Form], 1)
To highlight the control with focus only, use:
=SetupForm([Form], 2)

If your form's OnLoad property is set to [Event Procedure] add this line to the code:
Call SetupForm(Me)
Change the color scheme by assigning different values to the constants at the top of the
module. mlngcFocusBackColor defines the color when a control gains focus.mlngcRequiredBackColor defines
the color for required fields. Use RGB values (red, green, blue.) Note that:
In Datasheet view, only the asterisk shows (over the column heading)
In Continuous form view (where you typically have not attached labels), only the background color shows.
(You could modify the code with the CaptionFromHeader() function from the FindAsUType utility, so as to
bold the labels in the Form Header over the columns.)
Note that the labels will not be bolded or have the star added if they are not attached to the controls. To
reattach a label in form design view, cut it to clipboard, select the control to attach it to, and paste.

Limitations
The code highlights only text boxes, combo boxes, and list boxes.
A control will not highlight if it already has something in its On Got Focus or On Lost Focus properties.
Use OnEnter or OnExit for the existing code.

How it works
You can use the code without understanding how it works: this explanation is for those who want to learn how
it works, or modify what it does.
The main function SetupForm() accepts two arguments: a reference to the form you are setting up, and
an integer indicating what parts you want set up. The integer is optional, and defaults to all bits on (except the
sign.) We are actually only using the first two bits (for required and focus-color); you can use the remaining
bits for other things you want to set up on your form. SetupForm() examines the bits, and calls separate
functions to handle the required and focus-color issues.

Highlighting the control with focus


The OnGotFocus event fires when a control get focus, and its OnLostFocus event when focus moves away. We
can therefore use these events to highlight (by setting its BackColor) and restore it. But we needs these events
to fire for each control that can get focus. SetupFocusColor() assigns these properties for us when the form
loads.
So, SetupFocusColor() loops through each control on the form. It looks at the ControlType property, and skip
anything other than a text box, combo, or list box, and controls that are already
using OnGotFocus or OnLostFocus. It then sets property values this (using Text0 as an example):
Property

Setting

Comment

On Got Focus: =Hilight([Text0], True)

Text0 will be highlighted when it gets focus.


The square brackets cope with odd field names.

On Lost Focus: =Hilight([Text0], False)

Text0 will be restored to normal when it loses focus.

We will look in the Tag property to find what that is.

Tag:

UsualBackColor=13684991

This is the color to restore it to when it loses focus.


We append (after a semicolon) if Tag contains something.

Assigning these properties automatically when the form opens makes it easier to design and maintain.
Now, when any of these controls receives focus, it calls Hilight(), passing in a reference to itself, and a True
flag. When it loses focus, it calls Hilight() with a False flag.
If the flag is True (i.e. the control is gaining focus), Hilight() simply sets its BackColor to the value specified in
the constant mstrcTagBackColor. You can set that value to any number you wish at the top of the module. Just
use any valid RGB (red-green-blue) value.
If the flag is False (i.e. the control is losing focus), Hilight() needs to set it back to its old color. Our initialization
SetupFocusColor() stored the usual background color for the control in its Tag property. Tag could be
used for other things as well (typically separated by semicolons), so we call ReadFromTag() to parse the value
from the tag. If we get a valid number, we assign that to the BackColor. Otherwise (e.g. if some less polite code
overwrote the Tag), we assign the most likely background color (white.)

Highlighting required fields


SetupRequiredFields() is the function that provides the formatting for fields that are required.
Again, we loop through the controls, ignoring anything other than text box, combo, or list box. We also ignore
it if its Control Source is unbound (zero-length string), or bound to an expression (starts with =.) Otherwise the
Control Source must be the name of a field, so we look at that field in the Recordset of the form. If the field's
Required property is true, we will highlight it. We also check if the field's Validation Rule includes a statement
that it is not null: some developers prefer this to the Required property, as it allows them to use the field's
Validation Rule to give a custom message.
If we determined that the field is required, we set the BackColor of the control to the color specified in the
constant mlngcRequiredBackColor. Then we call MarkAttachedLabel() to format its label as well. The reason
for using a separate function here is that the control may not have an attached label, so an error is likely. It's
simplest to handle that error in a separate function.
If there is an attached label, it will be the first member of the Controls collection of our control Controls(0).
If there is no attached label, the error handler jumps out. Otherwise we add the asterisk to its Caption (unless
it already has one), and sets it to bold. Using bold looks good on continuous forms but does not show on
datasheets. The asterisk does show in the Column Heading in datasheet view. You can use whatever
formatting suits you.

COMBOS WITH TENS OF THOUSANDS OF RECORDS


Combos become unworkable with many thousands of records, even many hundreds in Access 2. By loading
records into the combo only after the user has typed the first three or four characters, you can use combos far
beyond their normal limits, even with the AutoExpand property on.
This is the idea:
Leave the combo's RowSource property blank.
Create a function that assigns the RowSource after a minimum number of characters has been typed. Only
entries matching these initial characters are loaded, so the combo's RowSource never contains more than a
few hundred records.
Call this function in the combo's Change event, and the form's Current event.

Example: Look up Postal Codes from Suburb


For this example you need a table named Postcodes, with fields Suburb, State, Postcode. You
may be able to create this table from downloaded data, for example postcodes for Australia. Make sure all
three fields are indexed.
You also need a combo with these properties:
Name

Suburb

RowSource
BoundColumn

ColumnCount

Step 1: Paste this into the General Declarations section of your form?s module:

Dim sSuburbStub As String


Const conSuburbMin = 3
Function ReloadSuburb(sSuburb As String)
Dim sNewStub As String

' First chars of Suburb.Text

sNewStub = Nz(Left(sSuburb, conSuburbMin),"")


' If first n chars are the same as previously, do nothing.

If sNewStub <> sSuburbStub Then


If Len(sNewStub) < conSuburbMin Then
'Remove the RowSource
Me.Suburb.RowSource = "SELECT Suburb, State,
Postcode FROM Postcodes WHERE (False);"
sSuburbStub = ""
Else
'New RowSource
Me.Suburb.RowSource = "SELECT Suburb, State,
Postcode FROM Postcodes WHERE (Suburb Like """ & _
sNewStub & "*"") ORDER BY Suburb, State,
Postcode;"
sSuburbStub = sNewStub
End If
End If
End Function

Step 2: In the form's Current event procedure, enter this line:

Call ReloadSuburb(Nz(Me.Suburb, ""))

Step 3: In the combo's Change event procedure, you could also use a single line. The code below illustrates
how to do a little more, blocking initial spaces, and forcing "Mt " to "Mount ":

Dim cbo As ComboBox


Dim sText As String

' Suburb combo.


' Text property of combo.

Set cbo = Me.Suburb


sText = cbo.Text
Select Case sText
Case " "
cbo = Null

' Remove initial space

Case "MT "

' Change "Mt " to "Mount ".

cbo = "MOUNT "


cbo.SelStart = 6
Call ReloadSuburb(sText)
Case Else

' Reload RowSource data.

Call ReloadSuburb(sText)
End Select
Set cbo = Nothing

Step 4: To assign the State and Postcode, add this code to the combo's AfterUpdate event procedure:

Dim cbo As ComboBox


Set cbo = Me.Suburb
If Not IsNull(cbo.Value) Then
If cbo.Value = cbo.Column(0) Then
If Len(cbo.Column(1)) > 0 Then
Me.State = cbo.Column(1)
End If
If Len(cbo.Column(2)) > 0 Then
Me.Postcode = cbo.Column(2)
End If
Else
Me.Postcode = Null
End If
End If
Set cbo = Nothing

The combo in Use

As the user types the first two characters, the drop-down list is empty. At the third character, the list fills with
just the entries beginning with those three characters. At the fourth character, Access completes the first
matching name (assuming the combo's AutoExpand is on). Once enough characters are typed to identify the
suburb, the user tabs to the next field. As they leave the combo, State and Postcode are assigned.
The time taken to load the combo between keystrokes is minimal. This occurs once only for each entry, unless
the user backspaces through the first three characters again.
If your list still contains too many records, you can reduce them by another order of magnitude by changing
the value of constant conSuburbMin from 3 to 4, i.e.:

Const conSuburbMin = 4

ADDING VALUES TO LOOKUP TABLES


Combo boxes give quick and accurate data entry:
accurate: you select an item from the list;
quick: a couple of keystrokes is often enough to select an item.
But how do you manage the items in the list? Access gives several options.

Option 1: Not In List event


When you enter something that is not in the combo's list, its Not In List event fires. Use this event to add the
new item to the RowSource table.
This solution is best for simple lists where there is only one field, such as choosing a category. You must set the
combo's Limit To List property to Yes, or the Limit To List event won't fire.
In the Northwind sample database, the Products form has a CategoryID combo. This example shows how to
add a new category by typing one that does not already exist in the combo:

Private Sub CategoryID_NotInList(NewData As String, Response As


Integer)
Dim strTmp As String

'Get confirmation that this is not just a spelling error.


strTmp = "Add '" & NewData & "' as a new product category?"
If MsgBox(strTmp, vbYesNo + vbDefaultButton2 + vbQuestion,
"Not in list") = vbYes Then

'Append the NewData as a record in the Categories table.


strTmp = "INSERT INTO Categories ( CategoryName ) " & _
"SELECT """ & NewData & """ AS CategoryName;"
DBEngine(0)(0).Execute strTmp, dbFailOnError

'Notify Access about the new record, so it requeries the


combo.
Response = acDataErrAdded

End If
End Sub

Option 2: Pop up a form


If the combo's source table has several fields, you need a form. Access 2007 gave combos a new property to
make this very easy.

Using the List Items Edit Form property (Access 2007 and later)
Just set this property to the name of the form that should be used to manage the items in the combo's list.
This approach is very simple, and requires no code.
The example below is for a CustomerID combo on an order form. When filling out an order, you can right-click
the combo to add a new customer.
The combo properties (design view)

The right-click shortcut menu (t

Limitations:
Previous versions of Access cannot do this.
The form is opened modally (dialog mode), so you cannot browse elsewhere to decide what to add.
The form does not open to the record you have in the combo. You have to move to a new record, or find the
one you want to edit. (You can set the form's Data Entry property to Yes, but this does not make it easy for a
user to figure out how to edit or delete an item.)

Using another event to open a form


To avoid these limitations, you could choose another event to pop up the form to edit the list. Perhaps the
combo's DblClick event, a custom shortcut menu, or the click of a command button beside the combo. This

approach does require some programming. There are several issues to solve here, since the edit form may
already be open.
Add code like this to the combo's event:

Private Sub CustomerID_DblClick(Cancel As Integer)


Dim rs As DAO.Recordset
Dim strWhere As String
Const strcTargetForm = "Customers"

'Set up to search for the current customer.


If Not IsNull(Me.CustomerID) Then
strWhere = "CustomerID = """ & Me.CustomerID & """"
End If

'Open the editing form.


If Not CurrentProject.AllForms(strcTargetForm).IsLoaded Then
DoCmd.OpenForm strcTargetForm
End If
With Forms(strcTargetForm)

'Save any edits in progress, and make it the active


form.
If .Dirty Then .Dirty = False
.SetFocus
If strWhere <> vbNullString Then
'Find the record matching the combo.
Set rs = .RecordsetClone
rs.FindFirst strWhere
If Not rs.NoMatch Then

.Bookmark = rs.Bookmark
End If
Else
'Combo was blank, so go to new record.
RunCommand acCmdRecordsGoToNew
End If
End With
Set rs = Nothing
End Sub

Then, in the pop up form's module, requery the combo:

Private Sub Form_AfterUpdate()


On Error GoTo Err_Handler
'Purpose:
its DblClick

Requery the combo that may have called this in

Dim cbo As ComboBox


Dim iErrCount As Integer
Const strcCallingForm = "Orders"

If CurrentProject.AllForms(strcCallingForm).IsLoaded Then
Set cbo = Forms(strcCallingForm)!CustomerID
cbo.Requery
End If

Exit_Handler:
Exit Sub

Err_Handler:

'Undo the combo if it has a partially entered value.


If (Err.Number = 2118) And (iErrCount < 3) And Not (cbo Is
Nothing) Then
cbo.Undo
Resume
End If
MsgBox "Error " & Err.Number & ": " & Err.Description
Resume Exit_Handler
End Sub

Private Sub Form_BeforeDelConfirm(Cancel As Integer, Response As


Integer)
If Response = acDeleteOK Then
Call Form_AfterUpdate
End If
End Sub

Option 3: Combos for entering free-form text


Set the combo's Limit To List property to No, and it lets you enter values values that are not in the list.
This approach is suitable for free-form text fields where the value might be similar to other records, but could
also be quite different. For example, a comments field where comments might be similar to another record,
but could be completely different.
The auto-expand makes it quick to enter similar comments, but it gives no accuracy. It is unnormalized, and
completely unsuitable if you might need to count or group by the lookup category. If the combo's bound
column is not the display column, you cannot set Limit To List to No.
To populate the combo's list, include DISTINCT in its Row Source, like this:
SELECT DISTINCT Comments FROM Table1 WHERE (Comments Is Not Null) ORDER
BY Comments;
Anything you type in the combo is saved in the Comments field. The list automatically shows the current items
next time you open the form.
This approach is useful in only very limited scenarios.

Option 4: Add items to a Value List


In a word, DON'T! No serious developer should let users add items to value lists, despite Access 2007
introducing a raft of new properties for this purpose.
If you set a combo's Row Source Type to Value List, you can enter the list of items (separated by
semicolons) in the Row Source. You might do this for a very limited range of choices that will never change
(e.g. "Male"; "Female.") But letting the user add items to the list almost guarantees you will end up with bad
data.

Managing the Value List in the Form


Access 2007 introduced the Allow Value List Edits property. If you set this to Yes, you can right-click the combo
and choose Edit List Items in the shortcut menu. Access opens a dialog where you can add items, remove
items, or edit the items in the list.
Let's ignore the fact that this doesn't work at all if the combo's Column Count is 2 or more. The real problem is
that there is no relational integrity:
You can remove items that are actually being used in other records.
You can correct a misspelled item, but the records that already have the misspelled item are not corrected.
You can add items to your form, but in a split database, other users don't get these items. Consequently, other
users add items with other names to their forms, even where they should be the same item.
If that's not bad enough, it gets worse when you close the form. Access asks:
Do you want to save the changes to the design of the form?
Regardless of how you answer that question, things go bad:
If you answer No after using one of the new items, you now have items in the data that don't match the list.
If you answer Yes in an unsplit database, you introduce strange errors as multiple users attempt to modify
objects that could be in use by other people.
If you answer Yes in an split database, the list of items in one front end no longer matches the lists in the
others.
Your changes don't last anyway: they are lost when the front end is updated.
There is no safe, reliable way for users to add items to the Value List in the form without messing up the
integrity of the data.

Managing the Value List in the Table


What about storing the value list in the table instead of the form? Access 2007 and later can do that, but again
it's unusable. Don't do it!
Some developers hate the idea of a combo in a table anyway. Particularly if the Bound Column is not the
display value, it confuses people by masking what is really stored there, not to mention the issues with the

wizard that creates this. For details, see The Evils of Lookup Fields in Tables. But lets ignore this wisdom,
and explore what happens of you store the value list in the table.
Select the field in table design, and in the lower pane (on the Lookup tab), set the properties like this:
Display Control

Combo Box

Row Source Type

Value List

Allow Value List Edits Yes


Row Source

"dog"; "cat"; "fish"

Now create a form using this table, with a combo for this field. Set the combo's Inherit Value List property to
Yes. Now Access ignores the Row Source list in the form, and uses the list from the table instead. If you edit
the list (adding, deleting, or modifying items), Access stores the changes in the properties of the field in the
table.
Does this solve the problems associated with keeping the list in the form? No, it does not.
If the database is split (so the table is attached), the changed Value List is updated in the linked table in the
front end only. It is not written to the real table in the back end. Consequently, the changed Value List is not
propagated to other users. We still have the same problem where each user is adding their own separate
items to the list. And we have the same problem where the user's changes are lost when the front end is
updated.
(Just for good measure, the Row Source of the field in the linked table does not display correctly after it has
been updated in this way, though the property is set if you examine it programmatically.)
At this point, it seems pointless to continue testing. One can also imagine multi-user issues with people
overwriting each others' entries as they edit the data if the database is not split.
There is no safe, reliable way for users to add items to the Value List without messing up the integrity of the
data.

Managing the Value List for Multi-Valued fields


Multi-valued fields (MVFs - introduced in Access 2007), suffer from the same issues if you let users edit their
value list.
The MVFs have one more property that messes things up even further: Show Only Row Source Values. If you
set this property to Yes, and allow users to modify the value list, itsuppresses the display of items that are no
longer in the list. A user can now remove an item from the list even though 500 records in your database
are using it. You will no longer see the value in any of the records where it is stored. At this point, not only
have you messed up the integrity of the data, you have also messed up the display of the data, so no end user
has any idea what is really stored in the database. (It can only be determined programmatically.)

USE A MULTI-SELECT LIST BOX TO FILTER A REPORT


This article explains how to use a multi-select list box to select several items at once, and open a report
limited to those items.
With a normal list box or text box, you can limit your report merely by placing a reference to the control in the
Criteria row of its query, e.g. [Forms].[MyForm].[MyControl]. You cannot do that with a multi-select
list box. Instead, loop through the ItemsSelected collection of the list box, generating a string to use with the
IN operator in the WHERE clause of your SQL statement.
This example uses the Products by Category report in the Northwind sample database.

The steps
Open the Northwind database.
Open the query named Products by Category in design view, and add Categories.CategoryID to the
grid. Save, and close.
Create a new form, not bound to any table or query.
Add a list box from the Toolbox. (View menu if you see no toolbox.)
Set these properties for the list box:
Name

lstCategory

Multi Select

Simple

Row Source Type

Table/Query

Row Source

SELECT Categories.CategoryID, Categories.CategoryName


FROM Categories ORDER BY Categories.CategoryName;

Column Count

Column Widths

Add a command button, with these properties:


Name

cmdPreview

Caption

Preview

On Click

[Event Procedure]

Click the Build button (...) beside the On Click property. Access opens the code window.
Paste the code below into the event procedure.
Access 2002 and later only: Open the Products by Category report in design view. Add a text box to the
Report Header section, and set its Control Source property to:

=[Report].[OpenArgs]
The code builds a description of the filter, and passes it with OpenArgs. See note 4 for earlier versions.

The code

Private Sub cmdPreview_Click()


On Error GoTo Err_Handler
'Purpose:
the list box.
'Author:

Open the report filtered to the items selected in

Allen J Browne, 2004.

http://allenbrowne.com

Dim varItem As Variant

'Selected items

Dim strWhere As String

'String to use as WhereCondition

Dim strDescrip As String

'Description of WhereCondition

Dim lngLen As Long

'Length of string

Dim strDelim As String

'Delimiter for this field type.

Dim strDoc As String

'Name of report to open.

'strDelim = """"
type. See note 1.

'Delimiter appropriate to field

strDoc = "Products by Category"

'Loop through the ItemsSelected in the list box.


With Me.lstCategory
For Each varItem In .ItemsSelected
If Not IsNull(varItem) Then
'Build up the filter from the bound column
(hidden).
strWhere = strWhere & strDelim &
.ItemData(varItem) & strDelim & ","
'Build up the description from the text in the
visible column. See note 2.

strDescrip = strDescrip & """" & .Column(1,


varItem) & """, "
End If
Next
End With

'Remove trailing comma. Add field name, IN operator, and


brackets.
lngLen = Len(strWhere) - 1
If lngLen > 0 Then
strWhere = "[CategoryID] IN (" & Left$(strWhere, lngLen)
& ")"
lngLen = Len(strDescrip) - 2
If lngLen > 0 Then
strDescrip = "Categories: " & Left$(strDescrip,
lngLen)
End If
End If

'Report will not filter if open, so close it. For Access 97,
see note 3.
If CurrentProject.AllReports(strDoc).IsLoaded Then
DoCmd.Close acReport, strDoc
End If

'Omit the last argument for Access 2000 and earlier. See
note 4.
DoCmd.OpenReport strDoc, acViewPreview,
WhereCondition:=strWhere, OpenArgs:=strDescrip

Exit_Handler:
Exit Sub

Err_Handler:
If Err.Number <> 2501 Then
error.

'Ignore "Report cancelled"

MsgBox "Error " & Err.Number & " - " & Err.Description,
, "cmdPreview_Click"
End If
Resume Exit_Handler
End Sub

PRINT A QUANTITY OF A LABEL


Need several labels for the same record? This tip works for a fixed number of labels (e.g. a whole sheet for
each client), or a variable number (where the quantity is in a field).

An unreliable approach
A common suggestion is to toggle NextRecord (a runtime property of the report) in the Format event of
the Detail section.
This approach works if the user previews/prints all pages of the report. It fails if only some pages are
previewed/printed: the events for the intervening pages do not fire, so the results are inconsistent.
This approach also fails in the new Report view in Access 2007 and later, since the events of the sections do
not fire in this view.

A Better Solution
A simpler and code-free solution uses a query with a record for each label. To do this, you need a table
containing a record from 1 to the largest number of labels you could ever need for any one record.
Create a new table, containing just one field named CountID, of type Number (Long Integer). Mark the field as
the primary key (toolbar icon). Save the table as tblCount.
Enter the records into this table manually, or use the function below to enter 1000 records instantly.
Create a query that contains both this table and the table containing your data. If you see any line joining the
two tables, delete it. It is the lack of a join that gives you a record for each combination. This is known as a
Cartesian Product.
Drag tblCount.CountID into the query's output grid. Use the Criteria row beneath this field to specify the
number of labels. For example, if your table has a field namedQuantity, enter:
<= [Quantity]
or if you always want 16 labels, enter:
<= 16
Include the other fields you want, and save the query. Use it as the RecordSource for your label report.
Optional: To print "1 of 5" on the label, add a text box to the report, with this in its ControlSource:
=[CountID] & " of " & [Quantity]
Ensure the Name of this text box is different from your field names (e.g. it can't be named "CountID" or
"Quantity"). To ensure the labels print in the correct order, include CountID in the report's Sorting And
Grouping dialog.
That's it.

Here's the function that will enter 1000 records in the counter table. Paste it into a module. Then press Ctrl+G
to open the Immediate window, and enter:
? MakeData()

Function MakeData()
'Purpose: Create the records for a counter table.
Dim db As Database
Dim lng As Long

'Current database.
'Loop controller.

Dim rs As DAO.Recordset

'Table to append to.

Const conMaxRecords As Long = 1000 'Number of records you want.

Set db = DBEngine(0)(0)
Set rs = db.OpenRecordset("tblCount", dbOpenDynaset, dbAppendOnly)
With rs
For lng = 1 To conMaxRecords
.AddNew
!CountID = lng
.Update
Next
End With
rs.Close
Set rs = Nothing
Set db = Nothing
MakeData = "Records created."
End Function

HAS THE RECORD BEEN PRINTED?

This question is usually asked as, "How can I mark a record as printed? Not just previewed - when it actually
goes to the printer?"
The question has some thorny aspects. Firstly, it is tricky to tell printing from previewing. Worse, printers run
out of ink/toner, or the paper jams and someone turns it off before the job really prints. It needs more than
just a yes/no field to mark the record as printed or not.
A better solution is to mark the records as part of a print run before they are sent to the printer. You can
then send the batch again if something goes wrong. You have a record of when the record was printed, and
you can can reprint a batch at any time.
So, instead of a yes/no field indicating if the record has printed, you use a Number field and store the batch
number. The number is blank until the record has been printed. Then it contains the number of the print
batch. If something goes wrong, you send the print run again.
Download the sample database (27kb zipped.) Requires Access 2000 or later.

Assign the number first, then print the batch


The database has a table where you enter new members (tblMember), and a table that tracks the print runs
(tblBatch.) When you enter a new member, you leave the BatchID blank.
When you are ready to print the new members, open frmBatch (shown above.) Click the Create New
Batch button. It creates a new entry in tblBatch, and assigns the new batch number to all the members that
have not been printed (i.e. BatchID is null.) Then clickPrint Selected Batch to print those records. It prints the
batch by filtering the report.

Not only do you know if a record was printed: you kwow when it was printed. If something goes wrong with
the printer, you send the batch again. You can even undo the batch, and recreate it if necessary.

Taking it further
This section explains a couple of ways to to extend the database beyond the example.

Track each time a record is printed


In some databases, you may want to track each time a record is printed.
Since one record can be printed many times, you need a related table to do this. The table has two fields:
BatchID - relates to tblBatch.BatchID
MemberID - relates to tblMember.MemberID
Now if batch 7 should contain 12 members, you add 12 records to this table. Then print the matching records
with a query that filters just the one batch.
You now have a complete history of each time a record was printed. (The sample database shows this table,
but does not demonstrate how to use it.)

Simplifying the Undo


The Undo button in sample database sets the BatchID to Null for all records in the batch, and then deletes the
batch number. The first of those two steps could be avoided if you use acascade-to-null relation.

CODE ACCOMPANYING ARTICLE: HAS THE RECORD BEEN


PRINTED?
The article Has the record been printed? shows how to create print runs (batches) that track when
new records are printed.
The code below lists the code behind the 3 buttons. Download the sample database if you prefer
(27 kb zipped, Access 2000 and later.)

Option Compare Database


Option Explicit

Private Sub cmdCreateBatch_Click()


'On Error GoTo Err_Handler
Dim db As DAO.Database
Dim rs As DAO.Recordset

Dim strSql As String


Dim lngBatchID As Long
Dim lngKt As Long

'Create the new batch, and get the number.


Set db = CurrentDb()
Set rs = db.OpenRecordset("tblBatch", dbOpenDynaset,
dbAppendOnly)
rs.AddNew
rs!BatchDateTime = Now()
lngBatchID = rs!BatchID
rs.Update
rs.Close

'Give this batch number to all members who have not been
printed.
strSql = "UPDATE tblMember SET BatchID = " & lngBatchID & "
WHERE BatchID Is Null;"
db.Execute strSql, dbFailOnError
lngKt = db.RecordsAffected

'Show the response.


Me.lstBatch.Requery
MsgBox "Batch " & lngBatchID & " contains " & lngKt & "
member(s)."

Exit_Handler:
Set rs = Nothing
Set db = Nothing
Exit Sub

Err_Handler:
MsgBox "Error " & Err.Number & ": " & Err.Description,
vbExclamation, "cmdCreateBatch_Click()"
Resume Exit_Handler
End Sub

Private Sub cmdPrintBatch_Click()


'On Error GoTo Err_Handler
Dim strWhere As String
Const strcDoc = "rptMemberList"

If IsNull(Me.lstBatch) Then
MsgBox "Select a batch to print."
Else
'Close the report if it's already open (so the filtering
is right.)
If CurrentProject.AllReports(strcDoc).IsLoaded Then
DoCmd.Close acReport, strcDoc
End If
'Open it filtered to the batch in the list box.
strWhere = "BatchID = " & Me.lstBatch
DoCmd.OpenReport strcDoc, acViewPreview, , strWhere
End If

Exit_Handler:
Exit Sub

Err_Handler:

MsgBox "Error " & Err.Number & ": " & Err.Description,
vbExclamation, ".cmdPrintBatch_Click"
Resume Exit_Handler
End Sub

Private Sub cmdUndoBatch_Click()


'On Error GoTo Err_Handler
Dim db As DAO.Database
Dim strSql As String
Dim varBatchID As Variant
Dim lngKt As Long

'Get the highest batch number.


varBatchID = DMax("BatchID", "tblBatch")
If IsNull(varBatchID) Then
MsgBox "No batches found."
Else
'Clear all the members of the batch.
Set db = CurrentDb()
strSql = "UPDATE tblMember SET BatchID = Null WHERE
BatchID = " & varBatchID & ";"
db.Execute strSql, dbFailOnError
'Delete the batch.
strSql = "DELETE FROM tblBatch WHERE BatchID = " &
varBatchID & ";"
db.Execute strSql, dbFailOnError
lngKt = db.RecordsAffected

'Show the response.


Me.lstBatch.Requery

MsgBox "Batch " & varBatchID & " deleted. " & lngKt & "
member(s) marked as not printed."
End If

Exit_Handler:
Set db = Nothing
Exit Sub

Err_Handler:
MsgBox "Error " & Err.Number & ": " & Err.Description,
vbExclamation, ".cmdUndoBatch_Click"
Resume Exit_Handler
End Sub

CASCADE TO NULL RELATIONS


Abstract: Highlights a little-known feature in Access, where related records can be automatically set to Null
rather than deleted when the primary record is deleted.

Introduction
Have you ever set up a table with a foreign key that is null until some batch operation occurs? A not-forprofit organisation might send thank you letters at the end of each period to acknowledge donors. The
Donation table therefore has a LetterID field that is Null until a batch routine is run to create a letter for each
donor, and assign this LetterID to each of the records in the Donation table that are acknowledged in the
letter.
So the user can undo the batch, you end up writing code to execute an Update query on the Donation table to
change LetterID back to Null for all letters in the batch, deletes the Letters from their table, and deletes
the BatchID from the Batch table.
Well, thats the way you used to code a batch undo! There is now a way to get JET (the data engine in Access)
to automatically set the LetterIDback to Null when the letters are deleted, at the engine level, without a
single line of code. Cascade-to-Null was introduced six years ago, but has remained below the radar for most
developers.
This article explains how to create this kind of cascading relation, with a simple example to use with
Northwind, and a sample database (13KB zipped) illustrating both DAO and ADOX approaches.
But first, a quick review of Nulls in foreign keys.

Referential Integrity and Nulls


When you create a relationship in Access, you almost always check the box for Referential Integrity (RI). This
little check box blocks invalid entries in the related table, and opens the door for cascading updates and
deletes.
What that check box does not do is prevent Nulls in the foreign key. In most cases, you must block this
possibility by setting the Required property of the foreign key field in its table. But there are cases where a Null
foreign key makes good sense. Batch operations like the receipt letters above are common. Even for
something as simple as items in a category, you might want to allow items that have no category, so the
CategoryID foreign key can be Null.

What is Cascade-to-Null?
We have mentioned three ways the database engine can enforce referential integrity:
Normal: Blocks the deletion or alteration of entries in the primary table if they are used in the related table.
Cascading Update: Automatically updates all matching entries in the related table when you change an entry
in the primary table.
Cascading Delete: Automatically deletes all matching entries in the related table when you delete an entry in
the primary table.

There is a fourth way the database could maintain RI: when a record is deleted from the primary table, it
could set the foreign key field of all related records to Null.
Benefits of Cascade-to-Null:
Related records are not lost!
Integrity is maintained. (There are no records with an invalid foreign key.)
The Null value in the foreign key perfectly represents the concept of unknown or unspecified.
Imagine a user created a goofy category in Northwind, and assigned it to several products. You need to delete
the category, but without losing the products. With this kind of relation between Categories and Products,
you can just delete the category, and all affected products become uncategorised. No code. No update
queries. No testing: the engine takes care of it for you.
This is cascade-to-null: when the primary record is deleted, the foreign key of the matching records is set to
Null automatically.

Creating a Cascade-to-Null relation


How has a feature this great remained unknown for most developers? Microsoft gave us the feature in Access
2000, but they never updated the interface. There is no Cascade-to-Null check box in the Edit Relationships
window. You can only create this kind of relation programmatically.
As the example below demonstrates, the code is very simple. These steps work with Northwind to replace the
relation between Products and Categories with a cascade-to-null.
Open Northwind.mdb in Access 2000 or later.
Open the Relationships window.
In Access 2000 - 2003, choose Relationships from the Tools menu.
In Access 2007 and later, click Relationships on the Database Tools tab of the ribbon.
Delete the existing relation between Products and Categories: right-click the line joining them, and choose
Delete. Close the Relationships window.
Create a new module.
In Access 2000 - 2003, select the Modules tab of the Database window, and click New.
In Access 2007 and later, choose Module (right-most icon) on the Create ribbon.
Paste the code below into the new module.
Run the code: open the Immediate Window (Ctrl+G), and enter:
Call MakeRel()
The response will be the value for the Cascade-to-Null relation attribute:
8192
Here's the code:

'Define the bit value for the relation Attributes.

Public Const dbRelationCascadeNull As Long = &H2000


Public Function MakeRel()
'Purpose: Create a Cascade-to-Null relation using DAO.
Dim db As DAO.Database
Dim rel As DAO.Relation
Dim fld As DAO.Field

Set db = CurrentDb()
'Arguments for CreateRelation(): any unique name, primary
table, related table, attributes.
Set rel = db.CreateRelation("CategoriesProducts",
"Categories", "Products", dbRelationCascadeNull)
Set fld = rel.CreateField("CategoryID")
primary table.

'The field from the

fld.ForeignName = "CategoryID"
from the related table.

'Matching field

rel.Fields.Append fld
the relation's Fields collection.

'Add the field to

db.Relations.Append rel
to the database.

'Add the relation

'Report and clean up.


Debug.Print rel.Attributes
Set db = Nothing
End Function

To test it, open the Categories table and enter a new category, with a name such as "Goofy Food", and close.
Open the Products table, and change the Category for a couple of products to this new category, and close.
Then open the Categories table again, and delete the Goofy Food category. You will see this dialog:

Choose Yes. Open the Products table, and you see that the products that you previously placed in the Goofy
Food category are now uncategorised. Deleting the Category caused them to cascade to Null.
(Note that Access does not have a dialog for Cascade-to-Null, so it uses the Cascade-Delete message.)

Maintaining Cascade-to-Null relations


Since the engine is maintaining the integrity of your data, this kind of relation means there are fewer update
queries to execute. This in turn means less code to write, since the engine takes care of this for you.
But what if someone else needs to rebuild the database at some stage? Since the interface cannot show them
that cascade-to-null relations are in force, they may recreate the tables and have no idea that your
application relies on this type of cascade. You need a way to document this, and ideally it should be visible in
the Relationships window.
Create a table purely for documentation. The table will never hold records. To ensure it shows in the
Relationships window, create a relation to other tables, so it is not only saved in the Relationships view now,
but shows up when the Show All Relationships button is clicked.
The field names can be anything, but since the goal is to catch attention, you might create a sentence using
odd names reserved words:
Field Name

Data Type

Description

* * * WARNING * * *

Text

Informational only: no data.

Cascade

Text

to

Text

Null

Text

Relations

Text

Exist

Text

On

Text

Products

Text

And

Text

Categories

Text

Id

Number

Primary key

Then open the Relationships window (Tools menu), and add the table. Drag the CategoryID field from the
Categories table to the Id field in your new table, and create the relationship.

A Real World Example


Cascade-to-Null is useful beyond the simple "category" example above. In fact, it is worth considering in any
relation where the foreign key is not required.
For example, you may have sales dockets that need to be collated into an invoice for each client at the end of
the month. Since the sales dockets will become line items of an invoice, they have an InvoiceID foreign key
that is null until the invoices are generated. The new invoices will be assigned a batch number, so the user can
undo the entire batch if something goes wrong, fix the data, and run the batching process again.
Using a cascade-to-null relation between the invoice and the original docket record means that if you delete
an invoice (or the whole batch), Access automatically updates all the sales items back to null. Next time the
batch process is run, your code recognises that the sales records are not part of an account, and so they pick
up those records automatically.

You probably have a cascading delete between your Batch table and Invoice table. So, you can now delete a
single batch record: the related invoices are deleted, and the originalsales dockets are cascaded to Null. No
code. No chance of making a mistake: it is all maintained by JET.

LIST BOX OF AVAILABLE REPORTS


In some applications you may wish to offer the user the choice to print any saved report. It would be handy to
have Access fill the names of all reports into a list box for the user to select and print. Here are two solutions.

Method 1: Query the Hidden System Table (undocumented)


This very simple approach queries the hidden system table Access itself uses to keep track of your reports. The
danger of an undocumented approach is that there is no guarantee it will work with future versions. Since
Microsoft has announced that the current version (4) is the last version of JET they will release, that problem
seems rather academic.
So, all you need do is copy this SQL statement and paste it into the RowSource of your list box:

SELECT [Name] FROM MsysObjects


WHERE (([Type] = -32764) AND ([Name] Not Like "~*") AND
([Name] Not Like "MSys*"))
ORDER BY [Name];
When an object is deleted but before the mdb is compacted, it is marked for deletion and assigned a name
starting with "~". The query skips those names.
Should you need to query other object types, the values for MSysObjects.Type are:
Table

Query

Form

-32768

Report

-32764

Module

-32761

Method 2: Callback Function


If you don't like the undocumented approach, or would like to experiment with call back functions, here is the
other alternative.
Create a form with:
a list box named lstReports, with a label Reports;
a check box named chkPreview, with a label Preview;
a command button named cmdOpenReport, with caption Open Report.
Set the command button's OnClick property to [Event Procedure]. Click the "..." button beside this property to
open the code window, and enter the following:

Private Sub cmdOpenReport_Click()


' Purpose:

Opens the report selected in the list box.

On Error GoTo cmdOpenReport_ClickErr


If Not IsNull(Me.lstReports) Then
DoCmd.OpenReport Me.lstReports, IIf(Me.chkPreview.Value,
acViewPreview, acViewNormal)
End If
Exit Sub

cmdOpenReport_ClickErr:
Select Case Err.Number
Case 2501

' Cancelled by user, or by NoData event.

MsgBox "Report cancelled, or no matching data.",


vbInformation, "Information"
Case Else
MsgBox "Error " & Err & ": " & Error$, vbInformation,
"cmdOpenReport_Click()"
End Select
Resume Next
End Sub

Set the list box's RowSourceType property to EnumReports. Leave the RowSource property blank.
Create a new module and copy the function below into this module:

Function EnumReports(fld As Control, id As Variant, row As


Variant, col As Variant, code As Variant) As Variant
' Purpose:

Supplies the name of all saved reports to a list

box.
' Usage:
EnumReports

Set the list box's RowSourceType property to:?

'

leaving its RowSource property blank.

' Notes:
automatically.

All arguments are provided to the function

' Author:
Feb.'97.

Allen Browne

allen@allenbrowne.com

Dim db As Database, dox As Documents, i As Integer


Static sRptName(255) As String
report names.

' Array to store

Static iRptCount As Integer


saved reports.

' Number of

' Respond to the supplied value of "code".


Select Case code
Case acLBInitialize
when form opens.

' Called once

Set db = CurrentDb()
Set dox = db.Containers!Reports.Documents
iRptCount = dox.Count
number of reports.

' Remember

For i = 0 To iRptCount - 1
sRptName(i) = dox(i).Name
names into array.

' Load report

Next
EnumReports = True
Case acLBOpen
EnumReports = Timer
unique identifier.
Case acLBGetRowCount

' Return a

' Number of rows

EnumReports = iRptCount
Case acLBGetColumnCount
EnumReports = 1

' 1 column

Case acLBGetColumnWidth

' 2 inches

EnumReports = 2 * 1440
Case acLBGetValue
name from the array.

' The report

EnumReports = sRptName(row)
Case acLBEnd
Erase sRptName

' Deallocate

array.
iRptCount = 0
End Select
End Function

How the callback function works


The RowSourceType property of a list box can be used to fill the box programmatically. The five arguments for
the function are provided automatically: Access calls the function repeatedly using these arguments to indicate
what information it is expecting.
During the initialization stage, this function uses DAO (Data Access Objects) to retrieve and store the names of
all reports into an static array. (Note: it is necessary to use theContainers!Reports.Documents collection, as
the Reports object refers only open reports.)
The command button simply executes an OpenReport action. If the check box is checked, the report is opened
in Preview mode, else it is printed directly.

FORMAT CHECK BOXES IN REPORTS


Consider using a text box in place of a check box on reports. You can then display crosses or check marks,
boxed or unboxed, any size, any color, with background colors, and even use conditional formatting.
The text box's Format property lets you specify a different format for negative values. Since, Access uses -1
for True, and 0 for False, the Format property lets you display any characters you want.
For this example, we use the check mark characters from the Wingdings font, since all Windows computers
have this font installed.

The steps
Open the report in design view.
If you already have a check box on your report, delete it.
Add a text box for your Yes/No field.
Set these properties:
Control Source:

Name of your yes/no field here.

Font Name:

WingDings

Width:

0.18 in

Type these characters into the Format property of the text box:
Hold down the Alt key, and type 0168 on the numeric keypad (the character for False),
semicolon (the separator between False and True formats),
backslash (indicating the next character is a literal),
Hold down the Alt key, and type 0254 on the numeric keypad (the character for True),
You can now increase the Font Size, set the Fore Color or Back Color, and so on.

The characters
Select the characters from this list:
Character

Keypad number

Description

Alt+0254

Checked box

Alt+0253

Crossed box

Alt+0252

Check mark (unboxed)

Alt+0251

Cross mark (unboxed)

Alt+0168

Unchecked box

To leave the text box blank for unchecked, omit the first character in the Format property, i.e. nothing before
the semi-colon.
You can find other characters with the Character Map applet that comes with all versions of Windows. There
are many other uses for these symbols, e.g. as graphics on your command buttons.

SORTING REPORT RECORDS AT RUNTIME


Access reports do their own sorting based on the sort fields you specify in the Sorting and Grouping dialog of
the report. The recordsource Order By clause is ignored.
Microsoft has a knowledgebase article that explains a technique for using setting the OrderBy property of a
report by opening the report in design view (Article ID: Q146310).
I have always preferred to programmatically set the group levels of the report, with code like this in the
Open event of the report:

Select Case Forms!frmChooseSort!grpSort


Case 1 'Name
Me.GroupLevel(0).ControlSource = "LastName"
Me.GroupLevel(1).ControlSource = "FirstName"
Me.GroupLevel(2).ControlSource = "Company"
Case 2 'Company
Me.GroupLevel(0).ControlSource = "Company"
Me.GroupLevel(1).ControlSource = "LastName"
Me.GroupLevel(2).ControlSource = "FirstName"
End Select

To make this work, you just need to make sure that you have set up the right number of grouping levels in the
report's grouping and sorting dialog.

Print a page with 3 evenly spaced mailing slips


The Label Wizard matches most mailing labels, but what if you need to divide a page into three slips with
address panels in exactly the same place? Most printers require a top and bottom margin, so just dividing the
remaining space by three does not place the address panels correctly.
The solution is to add a Group Footer section to act as a spacer between the slips. Set the height of this
spacer to the top margin plus the bottom margin. If you can then suppress this footer underneath the third
slip, you end up with a page that prints like this:
Letter

A4

0.5"

Top margin

0.5"

2.666"

Detail (1st mailing slip)

2.888"

1"

2.666"

1"

--------Group Footer (spacer)--------

Detail (2nd mailing slip)

--------Group Footer (spacer)--------

1"

2.888"

1"

Detail (3rd mailing slip)


2.888"

2.666"
(Group Footer suppressed)

0.5"

Bottom margin

0.5"

Steps to create the report


Create a report based on the table or query that contains the data.
In report design view, if you see Page Header and Page Footer sections, remove them by clicking Page
Header/Footer on the View menu in Access 95 - 2003. In Access 2007, on the Report Tools ribbon click Report
Header/Footer on the Show/Hide group (right-most icon.)
Set the Top and Bottom margins to 0.5". In Access 95 - 2003, choose Page Setup from the File menu. In
Access 2007,on the Report Tools ribbon, click the Extend arrow at the very bottom right corner of the Page
Setup group. If your printer requires the bottom margin to be greater, try 0.3" for the top margin and 0.7" for
the bottom. Use the diagram above to recalculate your heights if you need more than 1" in total: the sum of all
section heights must be 11" or less for Letter, 11.666" or less for A4.
Right-click the grey bar called Detail, and choose Properties. On the Format tab of the Properties box, set
the Height of the Detail section to 2.666" for Letter paper, 2.888" for A4.

Add a text box to the Detail section, to use as a counter. Give it these properties:
Control Source:

=1

Running Sum:

Over All

Name:

txtCount

Visible:

No

Create a Group Footer on your primary key field:


In Access 1 - 2003, open the Sorting and Grouping dialog (View menu).
Select your primary key field.
In the lower pane of the dialog, set Group Footer to Yes.
In Access 2007, on the Design ribbon, click Grouping in the Grouping and Totals group.
Select the primary key field.
Click More.
Click Without a Header.
Click With a Footer.
In the Properties box, set the Height of this group footer section to 1 inch (i.e. top margin plus bottom
margin).
(Optional.) Add a dotted line to the middle of this section to indicate where to cut the page.
To suppress this group footer below the third label on the page, set the On Format property of this section
to [Event Procedure].
Click the Build button (...) beside this. Add this line to the code window, so the event procedure looks like this:
Private Sub GroupFooter0_Format(Cancel As Integer, FormatCount As
Integer)
Me.GroupFooter0.Visible = (((Me.txtCount - 1) Mod 3) <> 2)
End Sub

Explanation of the code


The hidden text box accumulates 1 for each record. When the group footer is formatted, it examines the value
of the text box less 1. This yields 0 for the first record, 1 for the next, 2 for the next, 3 for the next, and so on.
The Mod operator gives for the remainder after division. Results will be 0, 1, 2, 0, 1, 2, 0, ... Every third record
has the value 2, and that is the bottom one on the page. So, if the result is different from 2, we set the group
section's Visible property to True. If is is not different from 2, the Visible property is False.
The technique can be easily adapted for other page sizes and numbers of slips.

REPORTS: PAGE TOTALS


Each section of a report has a Format event, where you can alter properties such as Visible, Color, Size, etc in a
manner similar to the Current in a form. (See Runtime Properties: Formsfor details.) For example to force
a page break when certain conditions are met, include a PageBreak control in the Detail Section and toggle its
Visible property. In addition, the Printevent can be used to perform tasks like adding the current record to a
running total.
You have an Amount field, and want to display the Amount total for that page in the Page Footer. Totals are
not normally available in the Page Footer, but the task requires just four lines of code!
In the PageHeader's Format event procedure, add:
curTotal = 0
'Reset the sum to zero each new Page.
In the DetailSection's Print event, add:
If PrintCount = 1 Then curTotal = curTotal + Me.Amount
Place an unbound control called PageTotal in the Page Footer. In the PageFooter's Format, add:
Me.PageTotal = curTotal
In the Code Window under Declarations enter:
Option Explicit
'Optional, but recommended for every module.
Dim curTotal As Currency 'Variable to sum [Amount] over a Page.
That's it!

REPORTS: A BLANK LINE EVERY FIFTH RECORD


In addition to the OnFormat and OnPrint events (see Reports: Page Totals for an example), Access 2 and
later provide three True/False properties:
MoveLayout: if False, prints on top of what was printed last;
NextRecord: if False, prints the same record again;
PrintSection: if False, doesn't print any data.
Each is normally set to True, but the combination of the three allows fine control over what is printed when
and where. For example, a report's readability might be enhanced by a blank line every five records.
In the report's Declarations enter:

Option Explicit
Dim fBlankNext As Integer 'Flag: print next line blank?
(True/False)
Dim intLine As Integer
'A line counter.

Select the Page Header section, and enter this in the OnFormat event procedure:

intLine = 0
fBlankNext = False

'Reset line counter at top of page.


'Never print first line of page blank.

Now select the Detail Section's OnPrint, and enter this code without the line numbers:

If PrintCount = 1 Then intLine = intLine + 1


If fBlankNext Then
Me.PrintSection = False
Me.NextRecord = False
fBlankNext = False
Else
Me.PrintSection = True
Me.NextRecord = True
fBlankNext = (intLine Mod 5 = 0)
End If

Need some explanation? In line 9, the statement inside the brackets evaluates to True when the line counter is
an exact multiple of 5 (i.e. the remainder is zero). This True/False result is assigned to fBlankNext, so this flag
becomes True every fifth record.
When the next record is about to print and fBlankNext is True, lines 3~5 will execute. MoveLayout is still True,
but PrintSection is False, so Access moves down a line and prints nothing. This gives a blank line, at the
expense of the record that wasn't printed! By setting NextRecord to False (and resetting our fBlankNext flag),
the missed record stays currentand is printed next time.

REPORTS: SNAKING COLUMN HEADERS


To get a snaking column header above each column but only if the column has data in it do the following:
Say you have two fields in the snaking column in the detail section of your report. ITEM and CARRYING.
Create two unbound fields in the detail section and name them ITEM HEADER and CARRYING HEADER.
Align them above the matching field getting just the way you want them (at the top of the detail section).
Set their properties to be:

Can grow: Yes


Visible: Yes
Height: 0.0007 in.
Now move the actual fields up just underneath the now very skinny headers.
In the properties for the detail section add an event procedure to the On Format. In it put the following code:

Sub Detail1_Format (Cancel As Integer, FormatCount As


Integer)
If Me.left <> dLastLeft Then
Me![ITEM HEADER] = "Item"
Me![CARRYING HEADER] = "Carrying"
dLastLeft = Me.left
sItem = Me![ITEM]
Else
If sItem = Me![ITEM] Then
Me![ITEM HEADER] = "Item"
Me![CARRYING HEADER] = "Carrying"
Else
Me![ITEM HEADER] = ""
Me![CARRYING HEADER] = ""
End If
End If

End Sub

Set up the fields dLastLeft and sItem in any module in the general section:

Global dLastLeft As DoubleGlobal, sItem As String


Compile and save the module.
Now when you print the report if only one column of the snaking report exist you will only get one heading.
But if two exist, you will get two headings.
From: Allen and LeAnne Jergensen (programmers who hate to give up!!)

DUPLEX REPORTS: START GROUPS ON AN ODD PAGE


To print invoices on a printer that supports double-siding, you need each one to start on an odd page.
Otherwise Fred's invoice begins on the back of Betty's.
The solution is to add an extra group footer, with the Force New Page property set to Yes. Then hide this
section if the next group will already begin on an odd page.
Download the sample database (26KB zipped, for Access 2000 and later.)
(Note: In Access 2007 and later, you must use Print or Print Preview for this to work. The events don't fire in
Report view or Layout view.)

Using a field twice in Sorting and Grouping


In the screenshot below, the OrderID field appears on two rows. In both cases, we chose Yes for Group Header
and Group Footer. This gives us two headers and two footers for OrderID.
The inner footer (the one nearest the Detail section) contains the totals for the order. Its Force New
Page property is set to "After Section", so the next section to print will start on a new page.
The outer footer also has Force New Page set to "After Section", so the next section (the header for the next
order) starts on another new page. This gives us a blank page between orders.
But if we are already on an odd page, we need to suppress the blank page. We do that by setting its Visible
property to No in its Format event.

The code
The outer OrderID Footer's Format event code looks like this:

Private Sub GroupFooter1_Format(Cancel As Integer, FormatCount


As Integer)
Dim bIsOdd As Boolean

bIsOdd = (Me.Page Mod -2)

'Yields 0 for even, or -1 for

odd.

'Hide this Section (and its page break) if already at odd


page.
With Me.Section("GroupFooter1")
If .Visible = bIsOdd Then
.Visible = Not bIsOdd
End If
End With
End Sub

The Mod operator gives the remainder after division. Mod -2 yields 0 for even pages, or -1 for odd pages. Since
0 is False, and -1 is True, we set the section's Visible property to the opposite.
So, the section (and its page break) is printed if the page is even, but suppressed if we are already on an odd
page.
The code toggles the Visible property only if needed, since setting a property is slower than reading it.

LOOKUP A VALUE IN A RANGE


There is a common approach to bracketed tables and lookups. It goes something like this:
BracketLow

BracketHigh

Rate

0.00

9.99

0.05

10.00

19.99

0.10

20.00

49.99

0.12

50.00

99999999.00

0.13

With this, use a query:

SELECT Rate
FROM Bracket
WHERE [Enter Bracket:]
BETWEEN BracketLow and BracketHigh
I would prefer not to use this solution.
As soon as you give users the ability to make mistakes, you have created problems. If users are allowed to
create brackets with both their beginning and ending points, they will almost certainly create brackets that
overlap or have gaps. The above table actually has gaps, which will become apparent if the value sought is
9.993. No rows would be returned by the query!
Instead, putting only one endpoint in each row of the Rate table is sufficient. While the query work is indeed
not as simple to write, it will perform well enough, as the number of rows in the Rate table would almost
certainly be few. Indeed, the index for the table would only be on this single value anyway, so that's the way
Access will find the row(s) necessary.
There is a principle in database construction not to store derivable values. This principle could be interpreted
to extend to this subject. You can derive the missing value, either upper or lower, of any bracket, as it is the
value in either the previous or subsequent row's value for lower or upper (respectively) when ordered by that
column.
The principle of not storing derivable values has exactly the same purpose in this case as in simpler cases,
where the derivation is just between columns of the current row. That principle is that, when the derivable
value is stored but not equal to what would be derived, then the stored value is incorrect, and the query will
malfunction on that basis. The alternative is to check the derived value against the stored value and replace it
where necessary. However, this entails a query at least as complex as the one you seek to avoid in just deriving
the "missing" value when needed.
The query I propose generally requires a subquery to find the proper bracket, and this is slightly daunting to
many who seek our advice here.

I expect that, by airing my point of view here, this will stimulate those we seek to assist to consider these
alternatives. So, I will illustrate my approach for their consideration.
At a point in the query you build, you require a Rate for further calculation, or just to display, or both. This rate
comes from a table of brackets something like this:
From

To

Rate

0.00

9.99

5%

10.00

19.99

10%

20.00

49.99

12%

50.00

13%

This last bracket represents "anything 50 or above."


There are two ways to store this: with the values in the From column, or with the values from the To column.
In this case, I would choose the "even, whole values" to be stored, that is, the From column. The table would
look like:
Minimum

Rate

0.00

0.05

10.00

.10

20.00

.12

50.00

.13

In this table, I propose the Primary Key is the From value.


If the "lookup" value is in a column of your query called Lookup, then the subquery returning rate would be:

(SELECT Rate
FROM RateTable RT1
WHERE From =
(SELECT MAX(Minimum)
FROM

RateTable RT2

WHERE Minimum <= Lookup))


Now this is a two tier subquery (yep, it's complex, and just the thing from which you probably wanted to
shield the poster). Indeed, this is a problem, because Access Jet doesn't seem to handle this well much of the
time. I believe that's because the Lookup in the inner query is two nesting levels away from its source in the
outer query.

So now the solution becomes (sadly) even more complex. Actually, for the person requesting assistance, this
may be better, however, as they can see what is happening step-by-step.
The solution is to build a query that has nearly the appearance of the original table with both From and To
columns, deriving the To column. However, I will provide a To column that is .01 large than my illustration. The
query using Lookup will have to find the bracket where Lookup >= From AND Lookup < To (NOT less than or
equal!!!)

SELECT Minimum,
(SELECT MIN(RT1.Minimum)
FROM RateTable RT1
WHERE RT1.Minimum > RT.Minimum)
AS Maximum,
Rate
FROM RateTable RT
If you wish, you could reproduce exactly the original values by subtracting 0.01 from this Maximum. I prefer
not to do this. If the query must calculate the value of Lookup, and the value is not rounded off to the nearest
"penny" then it is possible that Lookup would be 9.993. In the original Rate Table, there is no value of Rate for
9.993. I know that we humans would probably choose the rate for the bracket for 0.00 to 9.99, but the
computer will not do so. By deriving an upper limit as I have shown, and then restricting the comparison to be
less than that value, this can be overcome, eliminating any "gaps" in the bracket structure. This is where a
judicious choice of the column on which to base the actual data (the single endpoint approach) is useful, and
that's why I chose the "whole values" column for this basis.
There is really no substitute for remembering to round the value when Lookup is calculated in order to make
this work correctly. If you want 9.993 to be in the 0.00 to 9.99 bracket yet 9.996 to be in the 10.00 to 19.99
bracket, then you must round before using Lookup.

ACTION QUERIES: SUPPRESSING DIALOGS, WHILE


KNOWING RESULTS
Action queries change your data: inserting, deleting, or updating records.
There are multiple ways to run the query through macros or code.
This article recommends Execute in preference to RunSQL.

RunSQL
In a macro or in code, you can use RunSQL to run an action query. Using OpenQuery also works (just like
double-clicking an action query on the Query tab of the Database window), but it is a little less clear what the
macro is doing.
When you run an action query like this, Access pops up two dialogs:

A nuisance dialog:

Important details of
results and errors:
The SetWarnings action in your macro will suppress these dialogs. Unfortunately, it suppresses both. That
leaves you with no idea whether the action completed as expected, partially, or not at all.
The Execute method provides a much more powerful solution if you don't mind using code instead of a macro.

Execute
In a module, you can run an action query like this:
DBEngine(0)(0).Execute "Query1", dbFailOnError

The query runs without the dialogs, so SetWarnings is not needed. If you do want to show the results, the next
line is:
MsgBox DBEngine(0)(0).RecordsAffected & " record(s) affected."
If something goes wrong, using dbFailOnError generates an error you can trap. You can also use a
transaction and rollback on error.
However, Execute is not as easy to use if the action query has parameters such as [Forms].[Form1].[Text0]. If
you run that query directly from the Database Window or via RunSQL, theExpression Service (ES) in Access
resolves those names and the query works. The ES is not available in the Execute context, so the code gives an
error about "parameters expected."
It is possible to assign values to the parameters and execute the query, but it is just as easy to execute a string
instead of a saved query. You end up with fewer saved queries in the Database window, and your code is more
portable and reliable. It is also much more flexible: you can build the SQL string from only those boxes where
the user entered a value, instead of trying handle all the possible cases.
The code typically looks like this example, which resets a Yes/No field to No for all records:

Function UnpickAll()
Dim db As DAO.Database
Dim strSql As String

strSql = "UPDATE Table1 SET IsPicked = False WHERE IsPicked


= True;"
Set db = DBEngine(0)(0)
db.Execute strSql, dbFailOnError
MsgBox db.RecordsAffected & " record(s) were unpicked."
Set db = Nothing
End Function

TRUNCATION OF MEMO FIELDS


In Access tables, Text fields are limited to 255 characters, but Memo fields can handle 64,000 characters
(about 8 pages of single-spaced text) - even more programmatically. So why do memo fields sometimes get cut
off?

Queries
Access truncates the memo if you ask it to process the data based on the memo: aggregating, de-duplicating,
formatting, and so on.
Here are the most common causes, and how to avoid them:
Issue

Explanation

Workarounds

Aggregation

When you depress the button, Access


adds a Total row to the query design grid.
If you leave Group By under your memo
field, it must aggregate on the the memo,
so it truncates.

Choose First instead of Group By under


the memo field. The aggregation is still
preformed on other fields from the table,
but not on the memo, so Access can return
the full memo.
The field name changes (e.g.
FirstOfMyMemo), so change the name and
Control Source of any text boxes on
forms/reports.

Uniqueness

Since you asked the query to return only


distinct values, Access must compare the
memo field against all other records. The
comparison causes truncation.

Open the query's Properties Sheet and


set Unique Values to No. (Alternatively,
remove the DISTINCT key word in SQL
View.)
You may need to create another query that
selects the distinct values without the
memo, and then use it as the source for
another query that retrieves the memo
without de-duplicating.

Format property

The Format property processes the field,


Remove anything from the Format
e.g. forcing display in upper case (>) or
property of:
lower case (<). Access truncates the memo
the field in table design (lower pane);
to reduce this processing.
the field in query design (properties sheet);
the text box on your form/report.

UNION query

A UNION query combines values from


different tables, and de-duplicates them.
This means a comparing the memo field,

In SQL View, replace UNION with UNION


ALL.

resulting in truncation.
Concatenated fields

When you concatenate Text or Memo


fields in a query, Access treats the result as
a Text field (type dbText.) If you further
process this field (e.g. combining with
UNION ALL), it will truncate.
(See also Concatenated fields yield
garbage in recordset.)

Row Source

A Memo field in the Row Source of a


combo box or list box will truncate.

The first SELECT in a UNION query defines


the field type, so you can add another
UNION ALL using a Memo field so Access
gets the idea. For example, instead of:
SELECT ID, F1 & F2 AS
Result FROM Table1
UNION ALL SELECT ID, F1 &
F2 AS Result FROM Table2;
add a real memo field first (even though it
returns no records), like this:
SELECT ID, MyMemo FROM
Table3 WHERE (False)
UNION ALL SELECT ID, F1 &
F2 AS Result FROM Table1
UNION ALL SELECT ID, F1 &
F2 AS Result FROM Table2;
Don't use memo fields in combos or list
boxes.

Note that the same issues apply to expression that are longer than 255 characters, where Access must process
the expressions.

Why does it truncate?


Technically, there are good reasons why Access handles only the first 255 characters when it has to process
memo fields.
String operations are both processor and disk intensive. Performance would be slower than a sloth if Access
tried to compare all the thousands of characters of your memo field against all the other thousands of
characters in each of potentially millions of records. Some queries would take hours or even days to complete.
If that's not enough, don't forget the comparisons are more than mere memory matching. Some data sources
(e.g. Access 1 - 97 MDBs, text files) handle strings as bytes, while others (including JET 4 MDB and ACCDB files)
use Unicode. Unicode needs either more disk reads or more processing to decompress, and we expect it to
handle the conversions transparently and allow comparisons and joins across different types. Further, JET is
case-insensitive, and the characters map differently in different language settings. And some sources need
decryption as well.
The decision to handle only the first 255 characters is a perfectly reasonable compromise for a desktop
database like JET.

CROSSTAB QUERY TECHNIQUES


This article explains a series of tips for crosstab queries.

An example
A crosstab query is a matrix, where the column headings come from the values in a field. In the example
below, the product names appear down the left, the employee names become fields, and the intersection
shows how many of this product has been sold by this employee:

To create this query, open the Northwind sample database, create a new query, switch to SQL View (View
menu), and paste:

TRANSFORM Sum([Order Details].Quantity) AS SumOfQuantity


SELECT Products.ProductID, Products.ProductName, Sum([Order
Details].Quantity) AS Total
FROM Employees INNER JOIN (Products INNER JOIN (Orders INNER
JOIN [Order Details]
ON Orders.OrderID = [Order Details].OrderID)
ON Products.ProductID = [Order Details].ProductID)
ON Employees.EmployeeID = Orders.EmployeeID
GROUP BY Products.ProductID, Products.ProductName
PIVOT [Employees].[LastName] & ", " & [Employees].[FirstName];

Display row totals


To show the total of all the columns in the row, just add the value field again as a Row Heading.
In the example above, we used the Sum of the Quantity as the value. So, we added the Sum of Quantity again
as a Row Heading - the right-most column in the screenshot. (The total displays to the left of the employee
names.)
In Access 2007 and later, you can also show the total at the bottom of each column, by depressing the Totals
button on the ribbon. The button is on the Records group of the Home tab, and the icon is an upper case sigma
().

Display zeros (not blanks)


Where there are no values, the column is blank. Use Nz() if you want to show zeros instead. Since Access
frequently misunderstands expressions, you should also typecast the result. Use CCur() for Currency, CLng()
for a Long (whole number), or CDbl() for a Double (fractional number.)
Type the Nz() directly into the TRANSFORM clause. For the example above, use:
TRANSFORM CLng(Nz(Sum([Order Details].Quantity),0)) AS SumOfQuantity

Handle parameters
A query can ask you to supply a value at runtime. It pops up a parameter dialog if you enter something like
this:
[What order date]
Or, it can read a value from a control on a form:
[Forms].[Form1].[StartDate]
But, parameters do not work with crosstab queries, unless you:
a) Declare the parameter, or
b) Specify the column headings.
To declare the parameter, choose Parameters on the Query menu. Access opens a dialog. Enter the name and
specify the data type. For the examples above, use the Query Parameters dialog like this:
Parameter

Data Type

[What order date]

Date/Time

[Forms].[Form1].[StartDate]

Date/Time

[ OK ]

[ Cancel ]

Declaring your parameters is always a good idea (except for an Access bug in handling parameters of type
Text), but it is not essential if you specify your column headings.

Specify column headings

Since the column headings are derived from a field, you only get fields relevant to the data. So, if your criteria
limits the query to a period when Nancy Davolio made no sales, her field will not be displayed. If your goal is to
make a report from the crosstab, the report will give errors if the field named "Davolio, Nancy" just disappears.
To solve this, enter all the valid column headings into the Column Headings property of the crosstab query.
Steps:
In query design view, show the Properties box (View menu.)
Locate the Column Headings property. (If you don't see it, you are looking at the properties of a field instead of
the properties of the query.)
Type in all the possible values, separated by commas. Delimit text values with quotes, or date values with #.
For the query above, set the Column Headings property like this (on one line):
"Buchanan, Steven", "Callahan, Laura", "Davolio, Nancy", "Dodsworth, Anne", "Fuller, Andrew", "King, Robert",
"Leverling, Janet", "Peacock, Margaret", "Suyama, Michael"
Side effects of using column headings:
Any values you do not list are excluded from the query.
The fields will appear in the order you specify, e.g. "Jan", "Feb", "Mar", ...
Where a report has a complex crosstab query as its Record Source, specifying the column headings can speed
up the design of the report enormously. If you do not specify the column headings, Access is unable to
determine the fields that will be available to the report without running the entire query. But if you specify the
Column Headings, it can read the field names without running the query.
An alternative approach is to alias the fields so the names don't change. Duane Hookom has an example
of dynamic monthly crosstab reports.

Multiple sets of values


What if you want to show multiple sets of values at each matrix point? Say the crosstab shows products at the
left, and months across the top, and you want to show both the dollar value and number of products sold at
each intersection?
One solution is to add another unjoined table to get the additional set of columns in your crosstab (a Cartesian
product.) Try this example with the old Northwind sample database:
Create a table with one Text field called FieldName. Mark the field as primary key. Save the table with the
name tblXtabColumns.
Enter two records: the values "Amt" and "Qty" (without the quotes.)
Create a new query, and paste in the SQL statement below:

TRANSFORM Sum(IIf([FieldName]="Qty",[Quantity],[Quantity]*[Order
Details]![UnitPrice])) AS TheValue
SELECT Products.ProductName
FROM tblXtabColumns, Products INNER JOIN (Orders INNER JOIN
[Order Details]

ON Orders.OrderID = [Order Details].OrderID) ON


Products.ProductID = [Order Details].ProductID
WHERE (Orders.OrderDate Between #1/1/1998# And #3/31/1998#)
GROUP BY Products.ProductName
PIVOT [FieldName] & Month([OrderDate]);
The query will look like this:

It generates fields named Amt and the month number, and Qty and the month number:

You can then lay them out as you wish on a report.

SUBQUERY BASICS
Discovering subqueries is one of those "Eureka!" moments. A new landscape opens in front of you, and you
can do really useful things such as:
Read a value from the previous or next record in a table.
Select just the TOP (or most recent) 5 scores per client.
Choose the people who have not paid/ordered/enrolled in a period.
Express a value as a percentage of the total.
Avoid inflated totals where a record is repeated (due to multiple related records.)
Filter or calculate values from other tables that are not even in the query.

What is a subquery?
The SELECT query statement

This example shows basic SQL syntax.


It returns 3 fields from 1 table, applies criteria, and sorts the results:
SELECT CompanyID, Company, City
FROM Table1
WHERE (City = "Springfield")
ORDER BY Company;
The clauses must be in the right order. Line endings and brackets are optional.
A subquery is a SELECT query statement inside another query.
As you drag fields and type expressions in query design, Access writes a sentence describing what you asked
for. The statement is in SQL (see'quell) - Structured Query Language - the most common relational database
language, also used by MySQL, SQL Server, Oracle, DB2, FoxPro, dBase, and others.
If SQL is a foreign language, you can mock up a query like the subquery you need, switch it to SQL View, copy,
and paste into SQL View in your main query. There will be some tidying up to do, but that's the simplest way to
create a subquery.

Subquery examples
The best way to grasp subqueries is to look at examples of how to use them.

Identifying what is NOT there


A sales rep. wants to hound customers who have not placed any orders in the last 90 days:

SELECT Customers.ID, Customers.Company


FROM Customers
WHERE NOT EXISTS
(SELECT Orders.OrderID
FROM Orders
WHERE Orders.CustomerID = Customers.CustomerID
AND Orders.OrderDate > Date() - 90)
;
The main query selects two fields (ID and Company) from the Customers table. It is limited by the WHERE
clause, which contains the subquery.
The subquery (everything inside the brackets) selects Order ID from the Orders table, limited by two criteria: it
has to be the same customer as the one being considered in the main query, and the Order Date has to be in
the last 90 days.
When the main query runs, Access examines each record in the Customers table. To decide whether to include
the customer, it runs the subquery. The subquery finds any orders for that customer in the period. If it finds
any, the customer is excluded by the NOT EXISTS.

Points to note:
The subquery goes in brackets, without a semicolon of its own.
The Orders table is not even in the main query. Subqueries are ideal for querying about data in other tables.
The subquery does not have the Customers table in its FROM clause, yet it can refer to values in the main
query.
Subqueries are useful for answering questions about what data exists or does not exist in a related table.

Get the value in another record


Periodically, they read the meter at your house, and send a bill for the number of units used since the previous
reading. The previous reading is a different record in the same table. How can they query that?
A subquery can read another record in the same table, like this:
SELECT MeterReading.ID,
MeterReading.ReadDate,
MeterReading.MeterValue,
(SELECT TOP 1 Dupe.MeterValue
FROM MeterReading AS Dupe
WHERE Dupe.AddressID = MeterReading.AddressID
AND Dupe.ReadDate < MeterReading.ReadDate
ORDER BY Dupe.ReadDate DESC, Dupe.ID)
FROM MeterReading;

AS PriorValue

The main query here contains 4 fields: the primary key, the reading date, the meter value at that date, and a
fourth field that is the value returned from the subquery.
The subquery returns just one meter reading (TOP 1.) The WHERE clause limits it to the same address, and a
previous date. The ORDER BY clause sorts by descending date, so the most recent record will be the first one.

Points to note:
Since there are two copies of the same table, you must alias one of them. The example uses Dupe for the
duplicate table, but any name will do.
If the main query displays the result, the subquery must return a single value only. You get this error if it
returns multiple values:
At most one record can be returned by this subquery.
Even though we asked for TOP 1, Access will return multiple records if there is a tie, e.g. if there were two
meter readings on the same date. Include the primary key in the ORDER BY clause to ensure it can decide
which one to return if there are equal values.
The main query will be read-only (not editable.) That is always the case when the subquery shows a value in
the main query (i.e. when the subquery is in the SELECT clause of the main query.)

TOP n records per group


You want the three most recent orders for each client. Use a subquery to select the 3 top orders per client,
and use it to limit which orders are selected in the main query:
SELECT Orders.CustomerID, Orders.OrderDate, Orders.OrderID
FROM Orders
WHERE Orders.OrderID IN
(SELECT TOP 3 OrderID
FROM Orders AS Dupe
WHERE Dupe.CustomerID = Orders.CustomerID
ORDER BY Dupe.OrderDate DESC, Dupe.OrderID DESC)
ORDER BY Orders.CustomerID, Orders.OrderDate, Orders.OrderID;

Points to note:
Since we have two copies of the same table, we need the alias.
Like EXISTS in the first example above, there is no problem with the subquery returning multiple records. The
main query does not have to show any value from the subquery.
Adding the primary key field to the ORDER BY clause differentiates between tied values.

Year to date
A Totals query easily gives you a total for the current month, but to get a year-to-date total or a total from the
same month last year means another calculation from the same table but for a different period. A subquery is
ideal for this purpose.
SELECT Year([Orders].[OrderDate]) AS TheYear,
Month([Orders].[OrderDate]) AS TheMonth,
Sum([Order Details].[Quantity]*[Order Details].[UnitPrice]) AS
MonthAmount,
(SELECT Sum(OD.Quantity * OD.UnitPrice) AS
YTD
FROM Orders AS A INNER JOIN [Order Details] AS OD ON A.OrderID =
OD.OrderID

WHERE A.OrderDate >=


DateSerial(Year([Orders].[OrderDate]),1,1)
AND A.OrderDate <
DateSerial(Year([Orders].[OrderDate]),
Month([Orders].[OrderDate]) + 1,
1))
AS YTDAmount
FROM Orders INNER JOIN [Order Details] ON Orders.OrderID = [Order
Details].OrderID
GROUP BY Year([Orders].[OrderDate]), Month([Orders].[OrderDate]);

Points to note:
The subquery uses the same tables, so aliases them as A (for Orders) and OD (for Order Details.)
The date criteria are designed so you can easily modify them for financial years rather than calendar years.
Even with several thousand records in Order Details, the query runs instantaneously.

Delete unmatched records


The Unmatched Query Wizard (first dialog when you create a new query) can help you identify records in one
table that have no records in another. But if you try to delete the unmatched records, Access may respond
with, Could not delete from specified tables.
An alternative approach is to use a subquery to identify the records in the related table that have no match in
the main table. This example deletes any records in tblInvoice that have no matching record in the
tblInvoiceDetail table:
DELETE FROM tblInvoice
WHERE NOT EXISTS
(SELECT InvoiceID
FROM tblInvoiceDetail
WHERE tblInvoiceDetail.InvoiceID = tblInvoice.InvoiceID);

Delete duplicate records


This example uses a subquery to de-duplicate a table. "Duplicate" is defined as records that have the same
values in Surname and FirstName. We keep the one that has the lowest primary key value (field ID.)
DELETE FROM Table1
WHERE ID <> (SELECT Min(ID) AS MinOfID FROM Table1 AS Dupe
WHERE (Dupe.Surname = Table1.Surname)
AND (Dupe.FirstName = Table1.FirstName));
Nulls don't match each other, so if you want to treat pairs of Nulls as duplicates, use this approach:
DELETE FROM Table1
WHERE ID <> (SELECT Min(ID) AS MinOfID FROM Table1 AS Dupe
WHERE ((Dupe.Surname = Table1.Surname)
OR (Dupe.Surname Is Null AND Table1.Surname Is Null))
AND ((Dupe.FirstName = Table1.FirstName)
OR (Dupe.FirstName Is Null AND Table1.FirstName Is Null)));

Aggregation: Counts and totals


Instead of creating a query into another query, you can summarize data with a subquery.
This example works with Northwind, to show how many distinct clients bought each product:
SELECT Products.ProductID, Products.ProductName, Count(Q.CustomerID) AS
HowManyCustomers
FROM
(SELECT DISTINCT ProductID, CustomerID
FROM Orders INNER JOIN [Order Details] ON Orders.OrderID = [Order
Details].OrderID) AS Q
INNER JOIN Products ON Q.ProductID = Products.ProductID
GROUP BY Products.ProductID, Products.ProductName;

Points to note:
The subquery is in the FROM clause, where it easily replaces another saved query.
The subquery in the FROM clause can return multiple fields.
The entire subquery is aliased (as Q in this example), so the main query can refer to (and aggregate) its fields.
Requires Access 2000 or later.

Filters and searches


Since subqueries can look up tables that are not in the main query, they are very useful for filtering forms and
reports.
A Filter or WhereCondition is just a WHERE clause. A WHERE clause can contain a subquery. So, you can use a
subquery to filter a form or report. You now have a way to filter a form or report on fields in tables that are
not even in the RecordSource!
In our first example above, the main query used only the Customers table, and the subquery filtered it to
those who had no orders in the last 90 days. You could filter the Customers form in exactly the same way:

'Create a subquery as a filter string.


strWhere = "NOT EXISTS (SELECT Orders.OrderID FROM Orders " & _
"WHERE (Orders.CustomerID = Customers.CustomerID) AND (Orders.OrderDate > Date() - 90))"
'Apply the string as the filter of the form that has only the Customers table.
Forms!Customers.Filter = strWhere
Forms!Cusomters.FilterOn = True
'Or, use the string to filter a report that has only the Customers table.
DoCmd.OpenReport "Customers", acViewPreview, , strWhere

This technique opens the door for writing incredibly powerful searches. Add subqueries to the basic
techniques explained in the Search form article, and you can offer a search where the user can select criteria
based on any related table in the whole database.
The screenshot below is to whet your appetite for how you can use subqueries. The form is unbound, with
each tab collecting criteria that will be applied against related tables. The final RESULTS tab offers to launch
several reports which don't even have those tables. It does this by dynamically generating a huge
WhereCondition string that consists of several subqueries. The reports are filtered by the subqueries in the
string.

RANKING OR NUMBERING RECORDS


JET does not have features to rank/number rows as some other SQL implementations do, so this article
discusses some ways to do it.

Numbering in a report
To number records in a report, use the Sorting And Grouping to sort them in the correct order. You can then
show a sequence number just by adding a text box with these properties:
Control Source

=1

Running Sum

Over All

This is the easiest way to get a ranking, and also the most efficient to execute. However, tied results may not
be what you expect. If the third and fourth entries are tied, it displays them as 3 and 4, where you might want
1, 2, 3, 3, 5.

Numbering in a form
To add a row number to the records in a form, see Stephen Lebans Row Number.
This solution has the same advantage (fast) and disadvantage (handling tied results) as the report example
above.

Ranking in a query
To handle tied results correctly, count the number of records that beat the current row.
The example below works with the Northwind sample database. The first query calculates the total value of
each customer's orders (ignoring the Discount field.) The second query uses that to calculate how many
customers had a higher total value:
Value of all orders per customer

Ranking customers by value

SELECT Orders.CustomerID,
Sum([Quantity]*[UnitPrice]) AS TotalValue
FROM Orders INNER JOIN [Order Details]
ON Orders.OrderID = [Order
Details].OrderID
GROUP BY Orders.CustomerID;

SELECT qryCustomerValue.CustomerID,
qryCustomerValue.TotalValue,
(SELECT Count([CustomerID]) AS HowMany
FROM qryCustomerValue AS Dupe
WHERE Dupe.TotalValue >
qryCustomerValue.TotalValue)
AS BeatenBy
FROM qryCustomerValue;

The first step is a query that gives a single record for whatever you are trying to rank (customers in this case.)
This is the source for the ranking query, which uses a subquery to calculate how many beat the current
record.
The example above starts ranking at zero. Add 1 if you wish, i.e.
WHERE Dupe.TotalValue > qryCustomerValue.TotalValue) + 1 AS BeatenBy

Limitations
It can be frustrating to do anything with the ranking query:
Limitation

Workaround

You cannot sort by ranking.

Build yet another query using qryCustomerValueRank as


an input table.

It is not easy to supply criteria to


stacked queries.

Have the query read the criteria from a form, e.g.


[Forms].[Form1].[StartDate]

A report based on the query is likely to


Use a DCount() expression instead of the subquery.
fail with:
Multi-level group-by
(This is even slower to execute.)
not allowed.

Results may be incorrect.

If you strike this bug, force JET to materialize the subquery


with:
(SELECT TOP 100 PERCENT Count([CustomerID]) AS
HowMany

You may need to write the query results to a temporary table so you can use them efficiently.

Ranking with a temporary table


To use a temporary table for the query above:
Create a table with these fields:
Field Name

Data Type

Description

CustomerID

Number (Long Integer)

Primary key

TotalValue

Currency

the value being ranked

BeatenBy

Number (Long Integer)

the ranking

Change qryCustomerValueRank into an append query:


- In Access 2007 and 2010, click Append on the Design tab of the ribbon.
- In earlier versions, Append is on the Query menu.
Since the primary key cannot be null, add criteria to qryCustomerValueRank under CustomerID:
Is Not Null
Use code to populate the temporary table, and optionally open the report you based on the temporary table:

Sub cmdRank_Click()
Dim db As DAO.Database
Set db = CurrentDb()
db.Execute "DELETE FROM MyTempTable;", dbFailOnError
db.Execute "qryCustomerValueRank", dbFailOnError
DoCmd.OpenReport "Report1", acViewPreview
Set db = Nothing
End Sub

Using an AutoNumber instead


If you are not concerned with how ties are handled, you could avoid the ranking query and just use an
AutoNumber in your temp table to number the rows:
Add an AutoNumber column to the temporary table.

Change qryCustomerValue so it sorts Descending on the TotalValue.


Change it into an Append query.
To reset the AutoNumber after clearing out the temporary table, compact the database. Alternatively, reset
the seed programmatically.

COMMON QUERY HURDLES


This article addresses mistakes people often make that yield poor query performance.
We assume you have set up relational tables, with primary keys, foreign keys, and indexes on the fields you
search and sort on.

Use SQL rather than VBA


JET/ACE (the query engine in Access) uses Structured Query Language (SQL), as many databases do. JET can
also call Visual Basic for Applications code (VBA.) This radically extends the power of JET, but it makes no sense
to call VBA if SQL can do the job.

Is Null, not IsNull()


WHERE IsNull(Table1.Field1)
WHERE (Table1.Field1 Is Null)
Is Null is native SQL.
IsNull() is a VBA function call.
There is never a valid reason to call IsNull() in a query, when SQL can evaluate it natively.

IIf(), not Nz()


SELECT Nz(Table1.Field1,0) AS Amount
SELECT IIf(Table1.Field1 Is Null, 0, Table1.Field1) AS Amount
The Nz() function replaces Null with another value (usually a zero for numbers, or a zero-length string for text.)
The new value is a Variant data type, and VBA tags it with a subtype: String, Long, Double, Date, or whatever.
This is great in VBA: a function can return different subtypes at different times. But in a query, a column can be
only be ONE data type. JET therefore treats Variants as Text, since anything (numbers, dates, characters, ...) is
valid in a Text column.
The visual clue that JET is treating the column as Text is the way it left-aligns. Numbers and dates display rightaligned.
If you expected a numeric or date column, you now have serious problems. Text fields are evaluated
character-by-character. So 2 is greater than 19, because the first character (the 2) is greater than the first
character of the other text (the 1 in 19.) Similarly, 4/1/2009 comes after 1/1/2010 in a Text column, because 4
comes after 1.

Alarm bells should ring as soon as you see a column left-aligned as Text, when you expected it handled
numerically. Wrong records will be selected, and the sorting will be nonsense.
You could use typecast the expression with another VBA function call, but a better solution would be to let JET
do the work instead of calling VBA at all.
Instead of:
Nz(MyField,0)
use:
IIf(MyField Is Null, 0, MyField)
Yes: it's a little more typing, but the benefits are:
You avoid the function call to Nz().
You retain the intended data type.
The criteria are applied correctly.
The column sorts correctly.
This principle applies not just to Nz(), but to any VBA function that returns a Variant. It's just that Nz() is the
most common instance we see.
(Note: JET's IIf() is much more efficient than the similarly named function in VBA. The VBA one wastes time
calculating both the True and False parts, and generates errors if either part does not work out (even if that
part is not needed.) The JET IIf() does not have these problems.)

Domain aggregate functions


DLookup(), DSum(), etc are slow to execute. They involve VBA calls, Expression Service calls, and they waste
resources (opening additional connections to the data file.) Particularly if JET must perform the operation on
each row of a query, this really bogs things down.
A subquery will be considerably faster than a domain aggregate function. In most cases, a stacked query will
be faster yet (i.e. another saved query that you include as a "table" in this query.)
There are times when a domain aggregate function is still the best solution you have (e.g. where you need
editable results.) For those cases, it might help to use ELookup() instead of the built-in functions.

Craft your expressions to use indexes


The query will be much faster if the database can use an index to select records or sort them. Here are two
examples.
WHERE Year(Table1.MyDate) = 2008

WHERE (Table1.MyDate >= #1/1/2008#)


AND (Table1.MyDate < #1/1/2009#)

Criteria on calculated fields


In the example at right, the Year() function looks easier, but this will execute much slower.
For every record, JET makes a VBA function call, gets the result, and then scans the entire table to eliminate
the records from other years. Without the function call, JET could use the index to instantaneously select the
records for 2008. This will execute orders of magnitude faster.
(You could use WHERE Table1.MyDate Between #1/1/2008# And #12/31/2008#, but this misses any dates on
the final day that have a time component.)
Particularly in criteria or sorting, avoid VBA calls so JET can use the index.

SELECT ClientID, Surname & ", " + FirstName AS FullName


FROM tblClient
ORDER BY Surname & ", " & FirstName;
SELECT ClientID, Surname & ", " + FirstName AS FullName
FROM tblClient
ORDER BY Surname, FirstName, ClientID;

Sorting on concatenated fields


Picture a combo box for selecting people by name. The ClientID is hidden, and Surname and FirstNameare
concatenated into one column so the full name is displayed even when the combo is not dropped down.
Do not sort by the concatenated field! Sort by the two fields, so JET can use the indexes on the fields to
perform the sorting.

Optimize Totals queries


The JET query optimizer is very good, so you may find that simple queries are fast without the tips in this
section. It is still worth the effort to create the best queries you can, so they don't suddenly slow down when
you modify them.
SELECT ClientID, Count(InvoiceID) AS HowMany
FROM tblInvoice
GROUP BY ClientID
HAVING ClientID = 99;
SELECT ClientID, Count(InvoiceID) AS HowMany
FROM tblInvoice
WHERE ClientID = 99

GROUP BY ClientID;

WHERE versus HAVING


Totals queries (those with a GROUP BY clause) can have both a WHERE clause and a HAVING clause. The
WHERE is executed first - before aggregation; the HAVING is executed afterwards - when the totals have been
calculated. It makes sense, then, to put your criteria in the WHERE clause, and use the HAVING clause only
when you must apply criteria on the aggregated totals.
This is not obvious in the Access query designer. When you add a field to the design grid, Access sets the Total
row to Group By, and the temptation is type your criteria under that. If you do, the criteria end up in the
HAVING clause. To use the WHERE clause, add the field to the grid a second time, and choose Where in the
Total row.

FIRST versus GROUP BY


SELECT EmployeeID, LastName, Notes
FROM Employees
GROUP BY EmployeeID, LastName, Notes;
SELECT EmployeeID, First(LastName) AS FirstOfLastName,
First(Notes) AS FirstOfNotes
FROM Employees
GROUP BY EmployeeID;
When you add a field to a Totals query, Access offers Group By in the Total row. The default behavior,
therefore, is that Access must group on all these fields.
A primary key is unique. So, if you group by the primary key field, there is no need to group by other fields in
that table. You can optimize the query by choosing First instead of Group By in the Total row under the other
fields. First allows JET to return the value from the first matching record, without needing to group by the
field.
This makes a major difference with Memo fields. If you GROUP BY a memo (Notes in the example), Access
compares only the first 255 characters, and the rest are truncated! By choosing First instead ofGroup By, JET is
free to return the entire memo field from the first match. So not only is it more efficient; it actually solves the
the problem of memo fields being chopped off.
(A downside of using First is that the fields are aliased, e.g. FirstOfNotes.)

Split your Access database into data and application


Even if all your data is in Access itself, consider using linked tables. Store all the data tables in one MDB or
ACCDB file - the data file - and the remaining objects (queries, forms, reports, macros, and modules) in a
second MDB - the application file. In multi-user situations, each user receives a local copy of the application
file, linked to the tables in the single remote data file.

Why split?
There are significant advantages to splitting your application:
Maintenance: To update the program, just replace the application file. Since the data is in a separate file, no
data is overwritten.
Network Traffic: Loading the entire application (forms, controls, code, etc) across the network increases traffic
making your interface slower.

RECONNECT ATTACHED TABLES ON START-UP


If you have an Access application split into DATA.MDB and PRG.MDB (see Split your MDB file into data and
application ), and move the files to a different directory, all tables need to be reconnected. Peter Vukovic's
function handles the reconnection.
If your data and program are in different folders, see Dev Ashish's solution: Relink Access tables from code

Function Reconnect ()
'**************************************************************
'*
START YOUR APPLICATION (MACRO: AUTOEXEC) WITH THIS
FUNCTION
'*
AND THIS PROGRAM WILL CHANGE THE CONNECTIONS
AUTOMATICALLY
'*

WHEN THE 'DATA.MDB'

AND THE 'PRG.MDB'

'*

ARE IN THE SAME DIRECTORY!!!

'*

PROGRAMMING BY PETER VUKOVIC, Germany

'*

100700.1262@compuserve.com

'* ************************************************************
Dim db As Database, source As String, path As String
Dim dbsource As String, i As Integer, j As Integer

Set db = dbengine.Workspaces(0).Databases(0)
'*************************************************************
'*

RECOGNIZE THE PATH

'*************************************************************

For i = Len(db.name) To 1 Step -1


If Mid(db.name, i, 1) = Chr(92) Then
path = Mid(db.name, 1, i)
'MsgBox (path)
Exit For
End If

Next
'*************************************************************
'*

CHANGE THE PATH

AND

CONNECT

AGAIN

'*************************************************************

For i = 0 To db.tabledefs.count - 1
If db.tabledefs(i).connect <> " " Then
source = Mid(db.tabledefs(i).connect, 11)
'Debug.Print source
For j = Len(source) To 1 Step -1
If Mid(source, j, 1) = Chr(92) Then
dbsource = Mid(source, j + 1, Len(source))
source = Mid(source, 1, j)
If source <> path Then
db.tabledefs(i).connect = ";Database=" +
path + dbsource
db.tabledefs(i).RefreshLink
'Debug.Print ";Database=" + path +
dbsource
End If
Exit For
End If
Next
End If
Next
End Function

SELF JOINS
Sometimes a field contains data which refers to another record in the same table. For example, employees
may have a field called "Supervisor" containing the EmployeeID of the person who is their supervisor. To find
out the supervisor's name, the table must look itself up.
To ensure referential integrity, Access needs to know that only valid EmployeeIDs are allowed in the
Supervisor field. This is achieved by dragging two copies of the Employees tableinto the Relationships screen,
and then dragging SupervisorID from one onto EmployeeID in the other. You have just defined a self join.
You will become quite accustomed to working with self-joins if you are asked to develop a report for
printing pedigrees. The parents of a horse are themselves horses, and so will have their own records in the
table of horses. A SireID field and a DamID field will each refer to different records in the same table. To
define these two self-joins requires three copies of the table in the "Relationships" window. Now a full
pedigree can be traced within a single table.
Here are the steps to develop the query for the pedigree report:
Drag three copies of tblHorses onto a new query. For your own sanity, select tblHorses_1 and change
its alias property to Sire in the Properties window. Alias tblHorses_2 as Dam.
Drag the SireID field from tblHorses to the ID field in Sire. Since we want the family tree even if some entries
are missing, this needs to be an outer join, so double-click the line that defines the join and select 2 in the
dialog box.
Repeat step 2 to create an outer join between DamID in tblHorses and ID in Dam.
Now drag four more copies of tblHorses into the query window, and alias them with names like SiresSire,
SiresDam, DamsSire, and DamsDam.
Create outer joins between these four tables, and the appropriate fields in Sire and Dam.
Repeat steps 4 and 5 with eight more copies of the table for the next generation.
Drag the desired output fields from these tables into the query grid, and your query is ready to view.
Your query should end up like this:

And just in case you wish to create this query by copying the SQL, here it is:
SELECT DISTINCTROW TblHorses.Name, Sire.Name, Dam.Name, SiresSire.Name,
SiresDam.Name, DamsSire.Name, DamsDam.Name, SiresSiresSire.Name,
SiresSiresDam.Name, SiresDamsSire.Name, SiresDamsDam.Name,
DamsSiresSire.Name, DamsSiresDam.Name, DamsDamsSire.Name, DamsDamsDam.Name
FROM (((((((((((((TblHorses LEFT JOIN TblHorses AS Sire ON TblHorses.SireID
= Sire.ID) LEFT JOIN TblHorses AS Dam ON TblHorses.DamID = Dam.ID) LEFT
JOIN TblHorses AS SiresSire ON Sire.SireID = SiresSire.ID) LEFT JOIN
TblHorses AS SiresDam ON Sire.DamID = SiresDam.ID) LEFT JOIN TblHorses AS
DamsSire ON Dam.SireID = DamsSire.ID) LEFT JOIN TblHorses AS DamsDam ON
Dam.DamID = DamsDam.ID) LEFT JOIN TblHorses AS SiresSiresSire ON
SiresSire.SireID = SiresSiresSire.ID) LEFT JOIN TblHorses AS SiresSiresDam
ON SiresSire.DamID = SiresSiresDam.ID) LEFT JOIN TblHorses AS SiresDamsSire
ON SiresDam.SireID = SiresDamsSire.ID) LEFT JOIN TblHorses AS SiresDamsDam
ON SiresDam.DamID = SiresDamsDam.ID) LEFT JOIN TblHorses AS DamsSiresSire
ON DamsSire.SireID = DamsSiresSire.ID) LEFT JOIN TblHorses AS DamsSiresDam
ON DamsSire.DamID = DamsSiresDam.ID) LEFT JOIN TblHorses AS DamsDamsSire ON
DamsDam.SireID = DamsDamsSire.ID) LEFT JOIN TblHorses AS DamsDamsDam ON
DamsDam.DamID = DamsDamsDam.ID ORDER BY TblHorses.Name;

FIELD TYPE REFERENCE - NAMES AND VALUES FOR


DDL, DAO, AND ADOX
You can create and manage tables in Access using:
the interface (table design view);
Data Definition Language (DDL) query statements;
DAO code;
ADOX code.
Each approach uses different names for the same field types. This reference provides a comparison.
For calculated fields, Access 2010 reports the data type specified in the ResultType propety.
For code to convert the DAO number into a field type name, see FieldTypeName().

JET (Interface)

DDL (Queries)

TEXT (size)
Text

[4]

[1]

DAO constant / decimal /


hex [2]

ADOX constant / decimal / hex

dbText

10

dbComplexText

109 6D

adVarWChar

202 CA

[3]

[5]

CHAR (size)

dbText

[6]

10

adWChar

130 82

Memo

MEMO

dbMemo

12

adLongVarWChar

203 CB

BYTE

dbByte

adUnsignedTinyInt

17

11

dbComplexByte

102 66

dbInteger

adSmallInt

dbComplexInteger

103 67

dbLong

adInteger

dbComplexLong

104 68

dbSingle

adSingle

dbComplexSingle

105 69

Number: Byte

SHORT

Number: Integer

LONG

Number: Long

SINGLE

Number: Single

DOUBLE

dbDouble

dbComplexDouble

106 6A

dbGUID

15

dbComplexGUID

107 6B

dbDecimal

20

dbComplexDecimal

108 6C

adDouble

adGUID

72

48

Number: Double

GUID

Number: Replica

Number: Decimal

DECIMAL (precision,
scale) [7]

14 adNumeric

131 83

Date/Time

DATETIME

dbDate

adDate

Currency

CURRENCY

dbCurrency

adCurrency

Auto Number

COUNTER (seed,
increment) [8]

dbLong with attributes 4

adInteger with
attributes

Yes/No

YESNO

dbBoolean

adBoolean

11

OLE Object

LONGBINARY

dbLongBinary

11

adLongVarBinary

205 CD

Hyperlink

[9]

dbMemo with
attributes

12

adLongVarWChar with
203 CB
attributes

dbAttachment

101 65

dbBinary

Attachment
[10]

BINARY (size)

adVarBinary

204 CC

SET AUTONUMBERS TO START FROM ...


Resetting an AutoNumber to 1 is easy: delete the records, and compact the database.
But how do you force an AutoNumber to start from a specified value? The trick is to import a record with one
less than the desired number, and then delete it. The following sub performs that operation. For example, to
force table "tblClient" to begin numbering from 7500, enter:

Call SetAutoNumber("tblClient", 7500)

Sub SetAutoNumber(sTable As String, ByVal lNum As Long)


On Error GoTo Err_SetAutoNumber
' Purpose:
at lNum.

set the AutoNumber field in sTable to begin

' Arguments:

sTable = name of table to modify.

'

lNum = the number you wish to begin from.

' Sample use:

Call SetAutoNumber("tblInvoice", 1000)

Dim db As DAO.Database

' Current db.

Dim tdf As DAO.TableDef

' TableDef of sTable.

Dim i As Integer

' Loop counter

Dim fld As DAO.Field

' Field of sTable.

Dim sFieldName As String

' Name of the AutoNumber field.

Dim vMaxID As Variant


value.

' Current Maximum AutoNumber

Dim sSQL As String

' Append/Delete query string.

Dim sMsg As String

' MsgBox string.

lNum = lNum - 1
value.

' Assign to 1 less than desired

' Locate the auto-incrementing field for this table.


Set db = CurrentDb()
Set tdf = db.TableDefs(sTable)

For i = 0 To tdf.Fields.Count - 1
Set fld = tdf.Fields(i)
If fld.Attributes And dbAutoIncrField Then
sFieldName = fld.name
Exit For
End If
Next

If Len(sFieldName) = 0 Then
sMsg = "No AutoNumber field found in table """ & sTable
& """."
MsgBox sMsg, vbInformation, "Cannot set AutoNumber"
Else
vMaxID = DMax(sFieldName, sTable)
If IsNull(vMaxID) Then vMaxID = 0
If vMaxID >= lNum Then
sMsg = "Supply a larger number. """ & sTable & "." &
_
sFieldName & """ already contains the value " &
vMaxID
MsgBox sMsg, vbInformation, "Too low."
Else
' Insert and delete the record.
sSQL = "INSERT INTO " & sTable & " ([" & sFieldName
& "]) SELECT " & lNum & " AS lNum;"
db.Execute sSQL, dbFailOnError
sSQL = "DELETE FROM " & sTable & " WHERE " &
sFieldName & " = " & lNum & ";"
db.Execute sSQL, dbFailOnError
End If
End If

Exit_SetAutoNumber:
Exit Sub

Err_SetAutoNumber:
MsgBox "Error " & Err.Number & ": " & Err.Description, ,
"SetAutoNumber()"
Resume Exit_SetAutoNumber
End Sub

CUSTOM DATABASE PROPERTIES


Often you get the situation where you want to store a single item of data in the database, eg an Author Name,
a version, a language selection. The most usual way to do this is to define a global const in a module. This has
two problems, it is not updatable, and it is not easily accessible from outside the database. A better solution is
to make use of database properties.
DAO objects - tables, querydefs, formdefs and the database itself have a list of properties. You can add userdefined properties to Database, Field, Index, QueryDef and TableDef objects. This is something you do once for
the life of the object, so the best way to do it is via a bit of scrap code.

To add (say) a Copyright Notice to the database,


open a new module
create a function named (say) tmp:

Function Tmp()
Dim DB As Database
Dim P as Property
Set DB = DBEngine(0)(0)
Set P = DB.CreateProperty("Copyright Notice", DB_TEXT,
"(C) JT Software 1995")
DB.Properties.Append P
End Function
open the immediate window
run tmp by entering ?tmp(). this will add the property to the DB
run it again. This time it should give an error "Can't Append: Object
already in collection"
And that's it. Don't bother saving the function - the property is now
a permanent part of the database.
Now you need a function to get the copyright notice:

Function CopyRight()
Dim DB As Database
Set DB = DBEngine(0)(0)
CopyRight = DB.Properties![CopyRight Notice]
End Function

The interesting thing is that you can fetch the copyright notice from a different database from the current one:

Function CopyRight(filename as string)


Dim DB As Database
Set DB=OpenDatabase(filename)
CopyRight = DB.Properties![CopyRight Notice]
DB.Close
End Function

Perhaps a function to update the notice would be good too:

Function CopyRightUpd(filename as string)


Dim DB As Database
Set DB=OpenDatabase(filename)
DB.Properties![CopyRight Notice] = "(C) JT Software " &
Year(Now)
DB.Close
End Function

Tip 2.1 - Version control for split databases


Database properties are the way I prefer to do version control of split databases. To each database I add the
following properties:
Product=Database for Section XYZ
Component=GlobalData
Version=3
Compat=2
This means that this database contains global data for the database I wrote for the guys in XYZ. It is version 3,
but is compatible backward to version 2 (eg-just contains some longer field lengths on one of the tables).
On opening the front-end database, I grab the name of the data database from the connect property of one of
my linked tables, and then CheckCompat(extdb):

Function CheckCompat (ext As String) As Integer

Dim ws As WorkSpace
Dim DB As Database
Dim ver1 As Integer
Dim compat1 As Integer
Dim ver2 As Integer
Dim compat2 As Integer
Set ws = DBEngine(0)
Set DB = ws(0)
ver1 = db.properties!version
compat1 = db.properties!compat

On Error Resume Next


Set DB = ws.OpenDatabase(ext)
If Err Then
MsgBox "Can't open """ & ext & """: " & Error, 48
checkcompat = False
Exit Function
End If
ver2 = db.properties!verversion
compat2 = db.properties!compat
If Err Then
MsgBox "Can't check version on """ & ext & """t: " &
Error, 48
checkcompat = False

Exit Function
End If

If ver1 > ver2 And ver2 < compat1 Then

MsgBox "Can't link the specified data file. This


database requires a version " &
Format(CDbl(compat1) / 100,
"0.00") & " data file.", 48
checkcompat = False
Exit Function
ElseIf ver2 > ver1 And ver1 < compat2 Then
MsgBox "Can't link the specified data file. It requires
a version " &
Format(CDbl(compat2) / 100,
"0.00") & " forms database.", 48
checkcompat = False
Exit Function
End If

checkcompat = True
End Function

If the checkcompat is OK, I then do a refreshlink on all the attached tables.


The other properties are used when the user wants to link to a different data file. I check that the file they
want to link to:
1 - Is an access database
2 - Is of the same product as the current database
3 - Is a "data" component (ie, not a forms component)
4 - has an appropriate version.

Tip 2.2 - Serial Numbers without using counters (aka: Can I reset a counter to zero?)
There have been quite a few people on the comp.databases.ms-access newsgroup asking if you can reset a
counter to zero. Briefly, not really. If you need as serial number or usage count that persists after the database
is closed, a good way is to use a property named "SerialNo". As before, create a temporary function to create
the property:

Function Tmp()

Dim DB As Database
Set DB = DBEngine(0)(0)
DB.properties.Append DB.CreateProperty("SerialNo",
DB_LONG, 0)
End Function
And run it once from the immediate window. You then need one or two functions to access it

Function CurrSerial() as Long


Dim DB as DataBase
Set DB = DBengine(0)(0)
CurrSerial = DB.properties!SerialNo
End Function

Function NextSerial() as Long


Dim DB as DataBase
Set DB = DBengine(0)(0)
DB.properties!SerialNo = DB.properties!SerialNo + 1
NextSerial = DB.properties!SerialNo
End Function

Sub ResetSerial()
Dim DB as DataBase
Set DB = DBengine(0)(0)
DB.properties!SerialNo = 0
End Sub

ERROR HANDLING IN VBA


Every function or sub should contain error handling. Without it, a user may be left viewing the faulty code in a
full version of Access, while a run-time version just crashes. For a more detailed approach to error handling,
see FMS' article on Error Handling and Debugging.
The simplest approach is to display the Access error message and quit the procedure. Each procedure, then,
will have this format (without the line numbers):
1 Sub|Function SomeName()
2

On Error GoTo Err_SomeName

' Code to do something here.

' Initialize error handling.

4 Exit_SomeName:

' Label to resume after error.

' Exit before error handler.

Exit Sub|Function

' Label to jump to on error.

6 Err_SomeName:
7

MsgBox Err.Number & Err.Description ' Place error handling here.

Resume Exit_SomeName

' Pick up again and quit.

9 End Sub|Function
For a task where several things could go wrong, lines 7~8 will be replaced with more detail:
Select Case Err.Number
' Whatever number you anticipate.

Case 9999

' Use this to just ignore the line.

Resume Next
Case 999
Resume Exit_SomeName

' Use this to give up on the proc.


' Any unexpected error.

Case Else

Call LogError(Err.Number, Err.Description, "SomeName()")


Resume Exit_SomeName
End Select
The Case Else in this example calls a custom function to write the error details to a table. This allows you to
review the details after the error has been cleared. The table might be named "tLogError" and consist of:
Field Name

Data Type

Description

ErrorLogID

AutoNumber

Primary Key.

ErrNumber

Number

Long Integer. The Access-generated error number.

ErrDescription

Text

Size=255. The Access-generated error message.

ErrDate

Date/Time

System Date and Time of error. Default: =Now()

CallingProc

Text

Name of procedure that called LogError()

UserName

Text

Name of User.

ShowUser

Yes/No

Whether error data was displayed in MsgBox

Parameters

Text

255. Optional. Any parameters you wish to record.

Below is a procedure for writing to this table. It optionally allows recording the value of any
variables/parameters at the time the error occurred. You can also opt to suppress the display of information
about the error.

Function LogError(ByVal lngErrNumber As Long, ByVal strErrDescription


As String, _
strCallingProc As String, Optional vParameters, Optional bShowUser
As Boolean = True) As Boolean
On Error GoTo Err_LogError
' Purpose: Generic error handler.
' Logs errors to table "tLogError".
' Arguments: lngErrNumber - value of Err.Number
' strErrDescription - value of Err.Description
' strCallingProc - name of sub|function that generated the error.
' vParameters - optional string: List of parameters to record.
' bShowUser - optional boolean: If False, suppresses display.
' Author: Allen Browne, allen@allenbrowne.com

Dim strMsg As String

' String for display in MsgBox

Dim rst As DAO.Recordset

' The tLogError table

Select Case lngErrNumber


Case 0

Debug.Print strCallingProc & " called error 0."


Case 2501

' Cancelled

'Do nothing.
Case 3314, 2101, 2115

' Can't save.

If bShowUser Then
strMsg = "Record cannot be saved at this time." & vbCrLf &
_
"Complete the entry, or press <Esc> to undo."
MsgBox strMsg, vbExclamation, strCallingProc
End If
Case Else
If bShowUser Then
strMsg = "Error " & lngErrNumber & ": " &
strErrDescription
MsgBox strMsg, vbExclamation, strCallingProc
End If
Set rst = CurrentDb.OpenRecordset("tLogError", , dbAppendOnly)
rst.AddNew
rst![ErrNumber] = lngErrNumber
rst![ErrDescription] = Left$(strErrDescription, 255)
rst![ErrDate] = Now()
rst![CallingProc] = strCallingProc
rst![UserName] = CurrentUser()
rst![ShowUser] = bShowUser
If Not IsMissing(vParameters) Then
rst![Parameters] = Left(vParameters, 255)
End If
rst.Update
rst.Close
LogError = True
End Select

Exit_LogError:
Set rst = Nothing
Exit Function

Err_LogError:
strMsg = "An unexpected situation arose in your program." &
vbCrLf & _
"Please write down the following details:" & vbCrLf &
vbCrLf & _
"Calling Proc: " & strCallingProc & vbCrLf & _
"Error Number " & lngErrNumber & vbCrLf &
strErrDescription & vbCrLf & vbCrLf & _
"Unable to record because Error " & Err.Number & vbCrLf
& Err.Description
MsgBox strMsg, vbCritical, "LogError()"
Resume Exit_LogError
End Function

EXTENDED DLOOKUP()
The DLookup() function in Access retrieves a value from a table. For basic information on how to use
DLookup(), see Getting a value from a table.

Why a replacement?
DLookup() has several shortcomings:
It just returns the first match to finds. Since you cannot specify a sort order, the result is unpredictable. You
may even get inconsistent results from the same data (e.g. after compacting a database, if the table contains
no primary key).
Its performance is poor.
It does not clean up after itself (can result in Not enough databases/tables errors).
It returns the wrong answer if the target field contains a zero-length string.
ELookup() addresses those limitations:
An additional optional argument allows you to specify a sort order. That means you can specify which value to
retrieve: the min or max value based on any sort order you wish to specify.
It explicitly cleans up after itself.
It runs about twice as fast as DLookup(). (Note that if you are retrieving a value for every row of a query,
a subquery would provide much better performance.)
It correctly differentiates a Null and a zero-length string.
Limitations of ELookup():
If you ask ELookup() to concatenate several (not memo) fields, and more than 255 characters are returned,
you strike this Access bug:
Concatenated fields yield garbage in recordset.
DLookup() can call the expression service to resolve an argument such as:
DLookup("Surname", "Clients", "ClientID = [Forms].[Form1].[ClientID]")
You can resolve the last issue by concatenating the value into the string:
ELookup("Surname", "Clients", "ClientID = " &
[Forms].[Form1].[ClientID])
Before using ELookup() in a query, you may want to modify it so it does not pop up a MsgBox for every row if
you get the syntax wrong. Alternatively, if you don't mind a read-only result, a subquery would give you faster
results than any function.

How does it work?


The function accepts exactly the same arguments as DLookup(), with an optional fourth argument. It builds a
query string:
SELECT Expr FROM Domain WHERE Criteria ORDER BY OrderClause

This string opens a recordset. If the value returned is an object, the requested expression is a multi-value field,
so we loop through the multiple values to return a delimited list. Otherwise it returns the first value found, or
Null if there are no matches.
Note that ELookup() requires a reference to the DAO library. For information on setting a reference,
see References.

Public Function ELookup(Expr As String, Domain As String,


Optional Criteria As Variant, _
Optional OrderClause As Variant) As Variant
On Error GoTo Err_ELookup
'Purpose:
DLookup()

Faster and more flexible replacement for

'Arguments: Same as DLookup, with additional Order By


option.
'Return:

Value of the Expr if found, else Null.

'

Delimited list for multi-value field.

'Author:

Allen Browne. allen@allenbrowne.com

'Updated:
December 2006, to handle multi-value fields
(Access 2007 and later.)
'Examples:
'
1. To find the last value, include DESC in the
OrderClause, e.g.:
'
ELookup("[Surname] & [FirstName]",
"tblClient", , "ClientID DESC")
'
2. To find the lowest non-null value of a field,
use the Criteria, e.g.:
'
ELookup("ClientID", "tblClient", "Surname Is
Not Null" , "Surname")
'Note:

Requires a reference to the DAO library.

Dim db As DAO.Database

'This database.

Dim rs As DAO.Recordset
find.

'To retrieve the value to

Dim rsMVF As DAO.Recordset


multi-value fields.

'Child recordset to use for

Dim varResult As Variant

'Return value for function.

Dim strSql As String

'SQL statement.

Dim strOut As String


(multi-value field.)
Dim lngLen As Long
Const strcSep = ","
multi-value list.

'Output string to build up

'Length of string.
'Separator between items in

'Initialize to null.
varResult = Null

'Build the SQL string.


strSql = "SELECT TOP 1 " & Expr & " FROM " & Domain
If Not IsMissing(Criteria) Then
strSql = strSql & " WHERE " & Criteria
End If
If Not IsMissing(OrderClause) Then
strSql = strSql & " ORDER BY " & OrderClause
End If
strSql = strSql & ";"

'Lookup the value.


Set db = DBEngine(0)(0)
Set rs = db.OpenRecordset(strSql, dbOpenForwardOnly)
If rs.RecordCount > 0 Then
'Will be an object if multi-value field.
If VarType(rs(0)) = vbObject Then
Set rsMVF = rs(0).Value
Do While Not rsMVF.EOF

If rs(0).Type = 101 Then

'dbAttachment

strOut = strOut & rsMVF!FileName & strcSep


Else
strOut = strOut & rsMVF![Value].Value &
strcSep
End If
rsMVF.MoveNext
Loop
'Remove trailing separator.
lngLen = Len(strOut) - Len(strcSep)
If lngLen > 0& Then
varResult = Left(strOut, lngLen)
End If
Set rsMVF = Nothing
Else
'Not a multi-value field: just return the value.
varResult = rs(0)
End If
End If
rs.Close

'Assign the return value.


ELookup = varResult

Exit_ELookup:
Set rs = Nothing
Set db = Nothing
Exit Function

Err_ELookup:
MsgBox Err.Description, vbExclamation, "ELookup Error " &
Err.number
Resume Exit_ELookup
End Function

EXTENDED DCOUNT()
The built-in function - DCount() - cannot count the number of distinct values. The domain aggregate functions
in Access are also quite inefficient.
ECount() offers an extra argument so you can count distinct values. The other arguments are the same as
DCount().

Using ECount()
Paste the code below into a standard module. To verify Access understands it, choose Compile from the Debug
menu (in the code window.) In Access 2000 or 2002, you may need to add a reference to the DAO library.
You can then use the function anywhere you can use DCount(), such as in the Control Source of a text box on a
form or report.
Use square brackets around your field/table name if it contains a space or other strange character, or starts
with a number.

Examples
These examples show how you could use ECount() in the Immediate Window (Ctrl+G) in the Northwind
database:
Expression

Meaning

? ECount("*", "Customers")

Number of customers.

? ECount("Fax", "Customers")

Number of customers who have a fax


number.

? ECount("*", "Customers", "Country = 'Spain'")

Number of customers from Spain.

? ECount("City", "Customers", "Country = 'Spain'", True)

Number of Spanish cities where we


have customers.

? ECount("Region", "Customers")

Number of customers who have a


region.

? ECount("Region", "Customers", ,True)

Number of distinct regions

? ECount("*", "Customers", "Region Is Null")

Number of customers who have no


region.

You cannot embed a reference to a form in the arguments. For example, this will not work:
? ECount("*", "Customers", "City = Forms!Customers!City")
Instead, concatenate the value into the string:
? ECount("*", "Customers", "City = """ & Forms!Customers!City & """")
If you need help with the quotes, see Quotation marks within quotes.

The code

Public Function ECount(Expr As String, Domain As String,


Optional Criteria As String, Optional bCountDistinct As Boolean)
As Variant

On Error GoTo Err_Handler


'Purpose:
Enhanced DCount() function, with the ability to
count distinct.
'Return:

Number of records. Null on error.

'Arguments: Expr
= name of the field to count. Use
square brackets if the name contains a space.
'

Domain

= name of the table or query.

'

Criteria

= any restrictions. Can omit.

'
bCountDistinct = True to return the number of
distinct values in the field. Omit for normal count.
'Notes:
not.)

Nulls are excluded (whether distinct count or

'

Use "*" for Expr if you want to count the nulls

'

You cannot use "*" if bCountDistinct is True.

too.

'Examples: Number of customers who have a region:


ECount("Region", "Customers")
'
Number of customers who have no region:
ECount("*", "Customers", "Region Is Null")
'
Number of distinct regions: ECount("Region",
"Customers", ,True)
Dim db As DAO.Database
Dim rs As DAO.Recordset
Dim strSql As String

'Initialize to return Null on error.

ECount = Null
Set db = DBEngine(0)(0)

If bCountDistinct Then
'Count distinct values.
If Expr <> "*" Then
with the wildcard.

'Cannot count distinct

strSql = "SELECT " & Expr & " FROM " & Domain & "
WHERE (" & Expr & " Is Not Null)"
If Criteria <> vbNullString Then
strSql = strSql & " AND (" & Criteria & ")"
End If
strSql = strSql & " GROUP BY " & Expr & ";"
Set rs = db.OpenRecordset(strSql)
If rs.RecordCount > 0& Then
rs.MoveLast
End If
ECount = rs.RecordCount
distinct records.

'Return the number of

rs.Close
End If
Else
'Normal count.
strSql = "SELECT Count(" & Expr & ") AS TheCount FROM "
& Domain
If Criteria <> vbNullString Then
strSql = strSql & " WHERE " & Criteria
End If
Set rs = db.OpenRecordset(strSql)
If rs.RecordCount > 0& Then

ECount = rs!TheCount

'Return the count.

End If
rs.Close
End If

Exit_Handler:
Set rs = Nothing
Set db = Nothing
Exit Function

Err_Handler:
MsgBox Err.Description, vbExclamation, "ECount Error " &
Err.Number
Resume Exit_Handler
End Function

EXTENDED DAVG()
The DAvg() function built into Access lets you get the average of a field in a table, and optionally specify
criteria.
This EAvg() function extends that functionality, so you can get the average of just the TOP values (or
percentage) from the field. You can even specify a different field for sorting, e.g. to get the average of the 4
most recent values.

Using EAvg()
Paste the code below into a standard module. To verify Access understands it, choose Compile from the Debug
menu (in the code window.) In Access 2000 or 2002, you may need to add a reference to the DAO library.
You can then use the function anywhere you can use DAvg(), such as in the Control Source of a text box on a
form or report.
Use square brackets around your field/table name if it contains a space or other strange character, or starts
with a number.
The arguments to supply are:
strExpr: the field name or expression to average.

Examples
These examples show how you could use EAvg() in the Immediate Window (Ctrl+G) in the Northwind database:
Expression

Meaning

? EAvg("Quantity", "[Order Details]")

Average quantity in all orders.

? EAvg("Quantity", "[Order Details]", , 4)

Average quantity of the 4 top


orders.

? EAvg("[Quantity] * [UnitPrice]", "[Order Details]", , 5)

Average dollar value of the top


5 line items.

? EAvg("Freight", "Orders", , 0.25)

Average of the 25% highest


freight values.

? EAvg("Freight", "Orders", "Freight > 0", 8, "OrderDate DESC, OrderID DESC")

Average freight in the 8 most


recent orders that have freight.

The code

Public Function EAvg(strExpr As String, strDomain As String,


Optional strCriteria As String, _
Optional dblTop As Double, Optional strOrderBy As String) As
Variant
On Error GoTo Err_Error
'Purpose:
'Author:
2006.
'Requires:
'Return:
error.

Extended replacement for DAvg().


Allen Browne (allen@allenbrowne.com), November

Access 2000 and later.


Average of the field in the domain. Null on

'Arguments: strExpr

= the field name to average.

'

strDomain

= the table or query to use.

'

strCriteria = WHERE clause limiting the records.

'
dblTop
= TOP number of records to average.
Ignored if zero or negative.
'
than 1.
'

Treated as a percent if less

strOrderBy

= ORDER BY clause.

'Note:
The ORDER BY clause defaults to the expression
field DESC if none is provided.
'
However, if there is a tie, Access returns
more than the TOP number specified,
'
unless you include the primary key in the
ORDER BY clause. See example below.
'Example:
Return the average of the 4 highest quantities
in tblInvoiceDetail:
'
EAvg("Quantity", "tblInvoiceDetail",,4,
"Quantity DESC, InvoiceDetailID")
Dim rs As DAO.Recordset
Dim strSql As String
Dim lngTopAsPercent As Long

EAvg = Null

'Initialize to null.

lngTopAsPercent = 100# * dblTop


If lngTopAsPercent > 0& Then
'There is a TOP predicate
If lngTopAsPercent < 100& Then
as percent.

'Less than 1, so treat

strSql = "SELECT Avg(" & strExpr & ") AS TheAverage


" & vbCrLf & _
"FROM (SELECT TOP " & lngTopAsPercent & "
PERCENT " & strExpr
Else
as count.

'More than 1, so treat

strSql = "SELECT Avg(" & strExpr & ") AS TheAverage


" & vbCrLf & _
"FROM (SELECT TOP " & CLng(dblTop) & " " &
strExpr
End If
strSql = strSql & " " & vbCrLf & " FROM " & strDomain &
" " & vbCrLf & _
" WHERE (" & strExpr & " Is Not Null)"
If strCriteria <> vbNullString Then
strSql = strSql & vbCrLf & " AND (" & strCriteria &
") "
End If
If strOrderBy <> vbNullString Then
strSql = strSql & vbCrLf & " ORDER BY " & strOrderBy
& ") AS MySubquery;"
Else
strSql = strSql & vbCrLf & " ORDER BY " & strExpr &
" DESC) AS MySubquery;"
End If
Else

'There is no TOP predicate (so we also ignore any ORDER


BY.)
strSql = "SELECT Avg(" & strExpr & ") AS TheAverage " &
vbCrLf & _
"FROM " & strDomain & " " & vbCrLf & "WHERE (" &
strExpr & " Is Not Null)"
If strCriteria <> vbNullString Then
strSql = strSql & vbCrLf & " AND (" & strCriteria &
")"
End If
strSql = strSql & ";"
End If

Set rs = DBEngine(0)(0).OpenRecordset(strSql)
If rs.RecordCount > 0& Then
EAvg = rs!TheAverage
End If
rs.Close

Exit_Handler:
Set rs = Nothing
Exit Function

Err_Error:
MsgBox "Error " & Err.Number & ": " & Err.Description, ,
"EAvg()"
Resume Exit_Handler
End Function

ARCHIVE: MOVE RECORDS TO ANOTHER TABLE


A move consists of two action queries: Append and Delete. A transaction blocks the Delete if the Append did
not succeed. Transactions are not difficult, but there are several pitfalls.

Should I archive?
Probably not. If possible, keep the old records in the same table with the current ones, and use a field to
distinguish their status. This makes it much easier to query the data, compare current with old values, etc. It's
possible to get the data from different tables back together again with UNION statements, but it's slower, can't
be displayed as a graphic query, and the results are read-only.
Archiving is best reserved for cases where you won't ever need the old data, or there are overriding
considerations e.g. hundreds of thousands of records, with new ones being added constantly. The archive
table will probably be in a separate database.

The Steps
The procedure below consists of these steps:
Start a transaction.
Execute the append query.
Execute the delete query.
Get user confirmation to commit the change.
If anything went wrong at any step, roll back the transaction.

The Traps
Watch out for these serious traps when working with transactions:
Use dbFailOnError with the Execute method. Otherwise you are not notified of any errors, and the results
could be incomplete.
dbFailOnError without a transaction is not enough. In Access 95 and earlier, dbFailOnError rolled the entire
operation back, and the Access 97 help file wrongly claims that is still the case. (There is a correction in the
readme.) FromAccess 97 onwards, dbFailOnError stops further processing when an error occurs, but
everything up to the point where the error occurred is committed.
Don't close the default workspace! The default workspace--dbEngine(0)--is always open. You will set a
reference to it, but you are not opening it. Access will allow you to close it, but later you will receive unrelated
errors about objects that are no longer set or have gone out of scope. Remember: Close only what you open;
set all objects to nothing.
CommitTrans or Rollback, even after an error. The default workspace is always open, so an unterminated
transaction remains active even after your procedure ends! And since Access supports multiple transactions,
you can dig yourself in further and further. Error handling is essential, with the rollback in the error recovery
section. A flag indicating whether you have a transaction open is a practical way to manage this.

The Code
This example selects the records from MyTable where the field MyYesNoField is Yes, and moves them into a
table named MyArchiveTable in a different database file - C:\My Documents\MyArchive.mdb.
Note: Requires a reference to the DAO library.

Sub DoArchive()
On Error GoTo Err_DoArchive
Dim ws As DAO.Workspace
transaction).

'Current workspace (for

Dim db As DAO.Database

'Inside the transaction.

Dim bInTrans As Boolean

'Flag that transaction is active.

Dim strSql As String

'Action query statements.

Dim strMsg As String

'MsgBox message.

'Step 1: Initialize database object inside a transaction.


Set ws = DBEngine(0)
ws.BeginTrans
bInTrans = True
Set db = ws(0)

'Step 2: Execute the append.


strSql = "INSERT INTO MyArchiveTable ( MyField, AnotherField,
Field3 ) " & _
"IN ""C:\My Documents\MyArchive.mdb"" " & _
"SELECT SomeField, Field2, Field3 FROM MyTable WHERE
(MyYesNoField = True);"
db.Execute strSql, dbFailOnError

'Step 3: Execute the delete.


strSql = "DELETE FROM MyTable WHERE (MyYesNoField = True);"

db.Execute strSql, dbFailOnError

'Step 4: Get user confirmation to commit the change.


strMsg = "Archive " & db.RecordsAffected & " record(s)?"
If MsgBox(strMsg, vbOKCancel + vbQuestion, "Confirm") = vbOK
Then
ws.CommitTrans
bInTrans = False
End If

Exit_DoArchive:
'Step 5: Clean up
On Error Resume Next
Set db = Nothing
If bInTrans Then

'Rollback if the transaction is active.

ws.Rollback
End If
Set ws = Nothing
Exit Sub

Err_DoArchive:
MsgBox Err.Description, vbExclamation, "Archiving failed:
Error " & Err.number
Resume Exit_DoArchive
End Sub

LIST FILES RECURSIVELY


This article illustrates how to list files recursively in VBA.
Output can be listed to the immediate window, or (in Access 2002 or later) added to a list box.
See List files to a table if you would prefer to add the files to a table rather than list box.
See DirListBox() for Access 97 or earlier.
Or, Doug Steele offers some alternative solutions in Find Your Data.

Using the code


To add the code to your database:
Create a new module.
In Access 2007 and later, click Module (right-most icon) on the Create ribbon.
In older versions, click the Modules tab of the database window, and click New.
Access opens the code window.
Copy the code below, and paste into your new module.
Choose Compile in the Debug menu, to verify Access understands the code.
Save the module with a name such as ajbFileList.

In the Immediate window


To list the files in C:\Data, open the Immediate Window (Ctrl+G), and enter:
Call ListFiles("C:\Data")
To limit the results to zip files:
Call ListFiles("C:\Data", "*.zip")
To include files in subdirectories as well:
Call ListFiles("C:\Data", , True)

In a list box
To show the files in a list box:
Create a new form.
Add a list box, and set these properties:
Name
lstFileList
Row Source Type
Value List
Set the On Load property of the form to:
[Event Procedure]
Click the Build button (...) beside this. Access opens the code window. Set up the event procedure like this:
Private Sub Form_Load()
Call ListFiles("C:\Data", , , Me.lstFileList)
End Sub

The Code
Public Function ListFiles(strPath As String, Optional
strFileSpec As String, _
Optional bIncludeSubfolders As Boolean, Optional lst As
ListBox)
On Error GoTo Err_Handler
'Purpose:

List the files in the path.

'Arguments: strPath = the path to search.


'
differently.

strFileSpec = "*.*" unless you specify

'
bIncludeSubfolders: If True, returns results
from subdirectories of strPath as well.
'
lst: if you pass in a list box, items are added
to it. If not, files are listed to immediate window.
'
The list box must have its Row Source Type
property set to Value List.
'Method:
FilDir() adds items to a collection, calling
itself recursively for subfolders.
Dim colDirList As New Collection
Dim varItem As Variant

Call FillDir(colDirList, strPath, strFileSpec,


bIncludeSubfolders)

'Add the files to a list box if one was passed in. Otherwise
list to the Immediate Window.
If lst Is Nothing Then
For Each varItem In colDirList
Debug.Print varItem
Next
Else

For Each varItem In colDirList


lst.AddItem varItem
Next
End If

Exit_Handler:
Exit Function

Err_Handler:
MsgBox "Error " & Err.Number & ": " & Err.Description
Resume Exit_Handler
End Function

Private Function FillDir(colDirList As Collection, ByVal


strFolder As String, strFileSpec As String, _
bIncludeSubfolders As Boolean)
'Build up a list of files, and then add add to this list,
any additional folders
Dim strTemp As String
Dim colFolders As New Collection
Dim vFolderName As Variant

'Add the files to the folder.


strFolder = TrailingSlash(strFolder)
strTemp = Dir(strFolder & strFileSpec)
Do While strTemp <> vbNullString
colDirList.Add strFolder & strTemp
strTemp = Dir
Loop

If bIncludeSubfolders Then
'Build collection of additional subfolders.
strTemp = Dir(strFolder, vbDirectory)
Do While strTemp <> vbNullString
If (strTemp <> ".") And (strTemp <> "..") Then
If (GetAttr(strFolder & strTemp) And
vbDirectory) <> 0& Then
colFolders.Add strTemp
End If
End If
strTemp = Dir
Loop
'Call function recursively for each subfolder.
For Each vFolderName In colFolders
Call FillDir(colDirList, strFolder &
TrailingSlash(vFolderName), strFileSpec, True)
Next vFolderName
End If
End Function

Public Function TrailingSlash(varIn As Variant) As String


If Len(varIn) > 0& Then
If Right(varIn, 1&) = "\" Then
TrailingSlash = varIn
Else
TrailingSlash = varIn & "\"
End If
End If

End Function

ENABLING/DISABLING CONTROLS, BASED ON USER


SECURITY
In conjunction with using security work groups to limit/permit functionality to individual users, controls may
be enabled/ disabled at run time. Otherwise users will have to view a warning message box from Access,
when they try to do something they're not allowed to do.
Note that the permission assignments for workgroups are by table, query, form, macro, etc. So this type of
routine must be used to 'set - permissions' for individual controls. As in the example below, "viewers" must
have permission to see the mainswitch board form, but it is necessary to disable buttons on that form.
To do this:
create a table with username (key) and workgroup
create a usersform (autoform is good enough) based on table
open form in autoexe (hidden) to where condition [username]=CurrentUser()
set values of controls with On Open property based on usergroup.
For example:
If mainswtich board form has buttons to add, edit and report on records,you may set up a workgroup of
accounts that may only report, called viewers.
To disable the add and edit buttons use the OnOpen property of the mainswtich board form to run the
following:

if condition:

[Forms]![usersform]![workgroup]="viewers"

setvalue:

[Forms]![mainswitch]![reportsbutton].[Enabled] Yes
[Forms]![mainswitch]![addbutton].[Enabled] No
[Forms]![mainswitch]![editbutton].[Enabled] No

This will 'gray-out' and disable the add and edit buttons.

Returning more than one value from a function


A function can only have one return value. In Access 2, there were a couple of ways to work around this
limitation:
Use a parameter to define what you want returned. For example:

Function MultiMode(iMode As Integer) As String


Select Case iMode
Case 1
MultiMode = "Value for first Option"
Case 2
MultiMode = "Value for second Option"
Case Else
MultiMode = "Error"
End Select
End Function
Another alternative was to pass arguments whose only purpose was so the function could alter them:

Function MultiArgu(i1, i2, i3)


i1 = "First Return Value"
i2 = "Second Return Value"
i3 = "Third Return Value"
End Function
VBA (Access 95 onwards) allows you to return an entire structure of values. In database terms, this is
analogous to returning an entire record rather than a single field. For example, imagine an accounting
database that needs to summarize income by the categories Wages, Dividends, and Other. VBA allows you to
declare a user-defined type to handle this structure:

Public Type Income


Wages As Currency
Dividends As Currency
Other As Currency
Total As Currency
End Type

You can now use this structure as the return type for a function. In a real situation, the function would look up
your database tables to get the values, but the return values would be assigned like this:

Function GetIncome() As Income


GetIncome.Wages = 950
GetIncome.Dividends = 570
GetIncome.Other = 52
GetIncome.Total = GetIncome.Wages + GetIncome.Dividends
+ GetIncome.Other
End Function
To use the function, you could type into the Immediate Window:

GetIncome().Wages
(Note: the use of "Public" in the Type declaration gives it sufficient scope.)
Programmers with a background in C will instantly recognize the possibilities now that user-defined types can
be returned from functions. If you're keen, user-defined types can even be based on other user-defined types.

Copy SQL statement from query to VBA

Rather than typing complex query statements into VBA code, developers often mock up a query graphically,
switch it to SQL View, copy, and paste into VBA.
If you've done it, you know how messy it is sorting out the quotes, and the line endings.
Solution: create a form where you paste the SQL statement, and get Access to create the SQL string for you.

Creating the form


The form just needs two text boxes, and a command button. SQL statements can be quite long, so you put the
text boxes on different pages of a tab control.
Create a new form (in design view.)
Add a tab control.
In the first page of the tab control, add a unbound text box.
Set its Name property to txtSql.
Increase its Height and Width so you can see many long lines at once.
In the second page of the tab control, add another unbound text box.
Name it txtVBA, and increase its height and width.
Above the tab control, add a command button.
Name it cmdSql2Vba.
Set its On Click property to [Event Procedure].
Click the Build button (...) beside this property.
When Access opens the code window, set up the code like this:

Private Sub cmdSql2Vba_Click()


Dim strSql As String

'Purpose:
into VBA code.

Convert a SQL statement into a string to paste

Const strcLineEnd = " "" & vbCrLf & _" & vbCrLf & """"

If IsNull(Me.txtSQL) Then
Beep
Else
strSql = Me.txtSQL
strSql = Replace(strSql, """", """""")

'Double up any

quotes.
strSql = Replace(strSql, vbCrLf, strcLineEnd)
strSql = "strSql = """ & strSql & """"
Me.txtVBA = strSql
Me.txtVBA.SetFocus
RunCommand acCmdCopy
End If
End Sub

Using the form


To use the form:
Open your query in SQL View, and copy the SQL statement to clipboard (Ctrl+C.)
Paste into the first text box (Ctrl+V.)
Click the button.
Paste into a new line in your VBA procedure (Ctrl+V.)
Hint: If you want extra line breaks in your VBA code, press Enter to create those line breaks in the SQL View of
the query or in your form.

CONCATENATE VALUES FROM RELATED RECORDS


You have set up a one-to-many relationship, and now you want a query to show the records from the table on
the ONE side, with the items from the MANY side beside each one. For example if one company has many
orders, and you want to list the order dates like this:
Company

Order Dates

Acme Corporation

1/1/2007, 3/1/2007, 7/1/2000, 1/1/2008

Wright Time Pty Ltd 4/4/2007, 9/9/2007


Zoological Parasites
JET SQL does not provide an easy way to do this. A VBA function call is the simplest solution.

How to use the function


Add the function to your database:
In Access, open the code window (e.g. press Ctrl+G.)
On the Insert menu, click Module. Access opens a new module window.
Paste in the function below.
On the Debug menu, click Compile, to ensure Access understands it.
You can then use it just like any of the built-in functions, e.g. in a calculated query field, in the ControlSource of
a text box on a form or report, in a macro or in other code.
For the example above, you could set the ControlSource of a text box to:
=ConcatRelated("OrderDate", "tblOrders", "CompanyID = " & [CompanyID])
or in a query:
SELECT CompanyName,
& [CompanyID])
FROM tblCompany;

ConcatRelated("OrderDate", "tblOrders", "CompanyID = "

Bug warning: If the function returns more than 255 characters, and you use it in a query as the source for
another recordset, a bug in Access may return garbage for the remaining characters.

The arguments
Inside the brackets for ConcatRelated(), place this information:
First is the name of the field to look in. Include square brackets if the field contains non-alphanumeric
characters such as a space, e.g. "[Order Date]"
Second is the name of the table or query to look in. Again, use square brackets around the name if it contains
spaces.

Thirdly, supply the filter to limit the function to the desired values. This will normally be of the form:
"[ForeignKeyFieldName] = " & [PrimaryKeyFieldName]
If the foreign key field is Text (not Number), include quote marks as delimiters, e.g.:
"[ForeignKeyFieldName] = """ & [PrimaryKeyFieldName]
& """"
For an explanation of the quotes, see Quotation marks within quotes.
Any valid WHERE clause is permitted.
If you omit this argument, ALL related records will be returned.
Leave the fourth argument blank if you don't care how the return values are sorted.
Specify the field name(s) to sort by those fields.
Any valid ORDER BY clause is permitted.
For example, to sort by [Order Date] with a secondary sort by [Order ID], use:
"[Order Date], [Order ID]"
You cannot sort by a multi-valued field.
Use the fifth argument to specify the separator to use between items in the string.
The default separator is a comma and space.

Public Function ConcatRelated(strField As String, _


strTable As String, _
Optional strWhere As String, _
Optional strOrderBy As String, _
Optional strSeparator = ", ") As Variant
On Error GoTo Err_Handler
'Purpose:
records.
'Return:

Generate a concatenated string of related

String variant, or Null if no matches.

'Arguments: strField = name of field to get results from and


concatenate.
'

strTable = name of a table or query.

'
values.

strWhere = WHERE clause to choose the right

'
values.

strOrderBy = ORDER BY clause, for sorting the

'
strSeparator = characters to use between the
concatenated values.
'Notes:
1. Use square brackets around field/table names
with spaces or odd characters.

'
2. strField can be a Multi-valued field (A2007
and later), but strOrderBy cannot.
'
3. Nulls are omitted, zero-length strings (ZLSs)
are returned as ZLSs.
'
4. Returning more than 255 characters to a
recordset triggers this Access bug:
'

http://allenbrowne.com/bug-16.html

Dim rs As DAO.Recordset
Dim rsMV As DAO.Recordset
recordset
Dim strSql As String
Dim strOut As String
concatenate to.
Dim lngLen As Long
Dim bIsMultiValue As Boolean
multi-valued field.

'Related records
'Multi-valued field

'SQL statement
'Output string to

'Length of string.
'Flag if strField is a

'Initialize to Null
ConcatRelated = Null

'Build SQL string, and get the records.


strSql = "SELECT " & strField & " FROM " & strTable
If strWhere <> vbNullString Then
strSql = strSql & " WHERE " & strWhere
End If
If strOrderBy <> vbNullString Then
strSql = strSql & " ORDER BY " & strOrderBy
End If
Set rs = DBEngine(0)(0).OpenRecordset(strSql, dbOpenDynaset)
'Determine if the requested field is multi-valued (Type is
above 100.)
bIsMultiValue = (rs(0).Type > 100)

'Loop through the matching records


Do While Not rs.EOF
If bIsMultiValue Then
'For multi-valued field, loop through the values
Set rsMV = rs(0).Value
Do While Not rsMV.EOF
If Not IsNull(rsMV(0)) Then
strOut = strOut & rsMV(0) & strSeparator
End If
rsMV.MoveNext
Loop
Set rsMV = Nothing
ElseIf Not IsNull(rs(0)) Then
strOut = strOut & rs(0) & strSeparator
End If
rs.MoveNext
Loop
rs.Close

'Return the string without the trailing separator.


lngLen = Len(strOut) - Len(strSeparator)
If lngLen > 0 Then
ConcatRelated = Left(strOut, lngLen)
End If

Exit_Handler:
'Clean up
Set rsMV = Nothing

Set rs = Nothing
Exit Function

Err_Handler:
MsgBox "Error " & Err.Number & ": " & Err.Description,
vbExclamation, "ConcatRelated()"
Resume Exit_Handler
End Function

MINOFLIST() AND MAXOFLIST() FUNCTIONS


Access does not have functions like Min() and Max() in Excel, for selecting the least/greatest value from a list.
That makes sense in a relational database, because you store these values in a related table. So Access
provides DMin() and DMax() for retrieving the smallest/largest value from the column in the related table.
Occasionally, you still need to pick the minimum or maximum value from a list. The functions below do that.
They work with numeric fields, including currency and dates. They return Null if there was no numeric value
in the list.

Using the functions


To create them:
Create a new module. In Access 97 - 2003, click the Modules tab of the database window, and click New. In
Access 2007 and later, click the Create ribbon, and choose Module (the rightmost icon on the Other group.)
Access opens the code window.
Copy the code below, and paste into your code window.
Check that Access understands the code, by choosing Compile on the Debug menu.
Save the module with a name such as Module1.
Use them like any built-in function.
For example, you could put this in a text box:
=MinOfList(5, -3, Null, 0, 2)
Or you could type this into a fresh column of the Field row in a query that has three date fields:
MaxOfList([OrderDate], [InvoiceDate], [DueDate])

Function MinOfList(ParamArray varValues()) As Variant


Dim i As Integer

'Loop controller.

Dim varMin As Variant

'Smallest value found so far.

varMin = Null

'Initialize to null

For i = LBound(varValues) To UBound(varValues)


If IsNumeric(varValues(i)) Or IsDate(varValues(i)) Then
If varMin <= varValues(i) Then
'do nothing

Else
varMin = varValues(i)
End If
End If
Next

MinOfList = varMin
End Function

Function MaxOfList(ParamArray varValues()) As Variant


Dim i As Integer

'Loop controller.

Dim varMax As Variant

'Largest value found so far.

varMax = Null

'Initialize to null

For i = LBound(varValues) To UBound(varValues)


If IsNumeric(varValues(i)) Or IsDate(varValues(i)) Then
If varMax >= varValues(i) Then
'do nothing
Else
varMax = varValues(i)
End If
End If
Next

MaxOfList = varMax
End Function

Understanding the functions

The ParamArray keyword lets you pass in any number of values. The function receives them as an array. You
can then examine each value in the array to find the highest or lowest. TheLBound() and UBound() functions
indicate how many values were passed in, and the loop visits each member in the array.
Any nulls in the list are ignored: they do not pass the IsNumeric() test.
The return value (varMin or VarMax) is initialized to Null, so the function returns Null if no values are found. It
also means that if no values have been found yet, the line:
If varMin <= varValues(i) Then
evaluates to Null, and so the Else block executes. Since an If statement has three possible outcomes True, False, and Null - a "do nothing" for one is a convenient way to handle the other two. If that is new,
see Common errors with Null.
Note that the functions would yield wrong results if the return value was not initialized to Null. VBA initializes
it to Empty. In numeric comparisons, Empty is treated as zero. Since the function then has a zero already, it
would then fail to identify the lowest number in the list.

AGE() FUNCTION
Given a person's date-of-birth, how do you calculate their age? These examples do not work reliably:

Format(Date() - DOB, "yyyy")


DateDiff("y", DOB, Date)
Int(DateDiff("d", DOB, Date)/365.25)
DateDiff("y", ..., ...) merely subtracts the year parts of the dates, without reference to the month or day. This
means we need to subtract one if the person has not has their birthday this year. The following expression
returns True if the person has not had their birthday this year:

DateSerial(Year(Date), Month(DOB), Day(DOB)) > Date


True equates to -1, so by adding this expression, Access subtracts one if the birthday hasn't occurred.
The function is therefore:

Function Age(varDOB As Variant, Optional varAsOf As Variant) As


Variant
'Purpose:

Return the Age in years.

'Arguments: varDOB = Date Of Birth


'
varAsOf = the date to calculate the age at, or
today if missing.
'Return:

Whole number of years.

Dim dtDOB As Date


Dim dtAsOf As Date
Dim dtBDay As Date

'Birthday in the year of calculation.

Age = Null

'Initialize to Null

'Validate parameters
If IsDate(varDOB) Then
dtDOB = varDOB

If Not IsDate(varAsOf) Then

'Date to calculate age

from.
dtAsOf = Date
Else
dtAsOf = varAsOf
End If

If dtAsOf >= dtDOB Then


after person was born.

'Calculate only if it's

dtBDay = DateSerial(Year(dtAsOf), Month(dtDOB),


Day(dtDOB))
Age = DateDiff("yyyy", dtDOB, dtAsOf) + (dtBDay >
dtAsOf)
End If
End If
End Function

Text2Clipboard(), Clipboard2Text() - 32-bit


To collect data from an Access form for pasting to your your word processor, how about a double-click on the
form's detail section? The code for the DblClick event will be something like this:

Dim strOut as string


strOut = Me.Title & " " & Me.FirstName & " " & Me.Surname &
vbCrLf & _
Me.Address & vbCrLf & Me.City & "

" & Me.State & "

" &

Me.Zip
Text2Clipboard(strOut)
Notes:
This code will require modification if you use the 64-bit version of Office (not merely a 64-bit version of
Windows.)
Access 2007 and later support introduced Rich Text memo fields that contain embedded HTML tags. The
Text2Clipboard() function copies the tags, and then the appear literally when you paste them. To avoid this
situation, use the PlainText() function. In the example above, you would use:
Text2Clipboard(PlainText(strOut))

32-bit Declarations (for Access 95 and later). (16-bit version also available for Access 1
and 2.)
Declare Function abOpenClipboard Lib "User32" Alias "OpenClipboard" (ByVal Hwnd As Long) As
Long
Declare Function abCloseClipboard Lib "User32" Alias "CloseClipboard" () As Long
Declare Function abEmptyClipboard Lib "User32" Alias "EmptyClipboard" () As Long
Declare Function abIsClipboardFormatAvailable Lib "User32" Alias "IsClipboardFormatAvailable"
(ByVal wFormat As Long) As Long
Declare Function abSetClipboardData Lib "User32" Alias "SetClipboardData" (ByVal wFormat As
Long, ByVal hMem As Long) As Long
Declare Function abGetClipboardData Lib "User32" Alias "GetClipboardData" (ByVal wFormat As
Long) As Long
Declare Function abGlobalAlloc Lib "Kernel32" Alias "GlobalAlloc" (ByVal wFlags As Long, ByVal
dwBytes As Long) As Long
Declare Function abGlobalLock Lib "Kernel32" Alias "GlobalLock" (ByVal hMem As Long) As Long
Declare Function abGlobalUnlock Lib "Kernel32" Alias "GlobalUnlock" (ByVal hMem As Long) As
Boolean

Declare Function abLstrcpy Lib "Kernel32" Alias "lstrcpyA" (ByVal lpString1 As Any, ByVal lpString2 As
Any) As Long
Declare Function abGlobalFree Lib "Kernel32" Alias "GlobalFree" (ByVal hMem As Long) As Long
Declare Function abGlobalSize Lib "Kernel32" Alias "GlobalSize" (ByVal hMem As Long) As Long
Const GHND = &H42
Const CF_TEXT = 1
Const APINULL = 0
To copy to the clipboard:

Function Text2Clipboard(szText As String)


Dim wLen As Integer
Dim hMemory As Long
Dim lpMemory As Long
Dim retval As Variant
Dim wFreeMemory As Boolean

' Get the length, including one extra for a CHR$(0) at the end.
wLen = Len(szText) + 1
szText = szText & Chr$(0)
hMemory = abGlobalAlloc(GHND, wLen + 1)
If hMemory = APINULL Then
MsgBox "Unable to allocate memory."
Exit Function
End If
wFreeMemory = True
lpMemory = abGlobalLock(hMemory)
If lpMemory = APINULL Then
MsgBox "Unable to lock memory."
GoTo T2CB_Free

End If

' Copy our string into the locked memory.


retval = abLstrcpy(lpMemory, szText)
' Don't send clipboard locked memory.
retval = abGlobalUnlock(hMemory)

If abOpenClipboard(0&) = APINULL Then


MsgBox "Unable to open Clipboard. Perhaps some other application is using it."
GoTo T2CB_Free
End If
If abEmptyClipboard() = APINULL Then
MsgBox "Unable to empty the clipboard."
GoTo T2CB_Close
End If
If abSetClipboardData(CF_TEXT, hMemory) = APINULL Then
MsgBox "Unable to set the clipboard data."
GoTo T2CB_Close
End If
wFreeMemory = False

T2CB_Close:
If abCloseClipboard() = APINULL Then
MsgBox "Unable to close the Clipboard."
End If
If wFreeMemory Then GoTo T2CB_Free
Exit Function

T2CB_Free:
If abGlobalFree(hMemory) <> APINULL Then
MsgBox "Unable to free global memory."
End If
End Function
To paste from the clipboard:

Function Clipboard2Text()
Dim wLen As Integer
Dim hMemory As Long
Dim hMyMemory As Long

Dim lpMemory As Long


Dim lpMyMemory As Long

Dim retval As Variant


Dim wFreeMemory As Boolean
Dim wClipAvail As Integer
Dim szText As String
Dim wSize As Long

If abIsClipboardFormatAvailable(CF_TEXT) = APINULL Then


Clipboard2Text = Null
Exit Function
End If

If abOpenClipboard(0&) = APINULL Then

MsgBox "Unable to open Clipboard. Perhaps some other application is using it."
GoTo CB2T_Free
End If

hMemory = abGetClipboardData(CF_TEXT)
If hMemory = APINULL Then
MsgBox "Unable to retrieve text from the Clipboard."
Exit Function
End If
wSize = abGlobalSize(hMemory)
szText = Space(wSize)

wFreeMemory = True

lpMemory = abGlobalLock(hMemory)
If lpMemory = APINULL Then
MsgBox "Unable to lock clipboard memory."
GoTo CB2T_Free
End If

' Copy our string into the locked memory.


retval = abLstrcpy(szText, lpMemory)
' Get rid of trailing stuff.
szText = Trim(szText)
' Get rid of trailing 0.
Clipboard2Text = Left(szText, Len(szText) - 1)
wFreeMemory = False

CB2T_Close:
If abCloseClipboard() = APINULL Then
MsgBox "Unable to close the Clipboard."
End If
If wFreeMemory Then GoTo CB2T_Free
Exit Function

CB2T_Free:
If abGlobalFree(hMemory) <> APINULL Then
MsgBox "Unable to free global clipboard memory."
End If
End Function

TABLEINFO() FUNCTION
This function displays in the Immediate Window (Ctrl+G) the structure of any table in the current database.
For Access 2000 or 2002, make sure you have a DAO reference.
The Description property does not exist for fields that have no description, so a separate function handles that
error.
The code

Function TableInfo(strTableName As String)


On Error GoTo TableInfoErr
' Purpose: Display the field names, types, sizes and descriptions for a table.
' Argument: Name of a table in the current database.
Dim db As DAO.Database
Dim tdf As DAO.TableDef
Dim fld As DAO.Field

Set db = CurrentDb()
Set tdf = db.TableDefs(strTableName)
Debug.Print "FIELD NAME", "FIELD TYPE", "SIZE", "DESCRIPTION"
Debug.Print "==========", "==========", "====", "==========="

For Each fld In tdf.Fields


Debug.Print fld.Name,
Debug.Print FieldTypeName(fld),
Debug.Print fld.Size,
Debug.Print GetDescrip(fld)
Next
Debug.Print "==========", "==========", "====", "==========="

TableInfoExit:
Set db = Nothing
Exit Function

TableInfoErr:
Select Case Err
Case 3265& 'Table name invalid
MsgBox strTableName & " table doesn't exist"
Case Else
Debug.Print "TableInfo() Error " & Err & ": " & Error
End Select
Resume TableInfoExit
End Function

Function GetDescrip(obj As Object) As String


On Error Resume Next
GetDescrip = obj.Properties("Description")
End Function

Function FieldTypeName(fld As DAO.Field) As String


'Purpose: Converts the numeric results of DAO Field.Type to text.
Dim strReturn As String 'Name to return

Select Case CLng(fld.Type) 'fld.Type is Integer, but constants are Long.


Case dbBoolean: strReturn = "Yes/No"

'1

Case dbByte: strReturn = "Byte"

'2

Case dbInteger: strReturn = "Integer"


Case dbLong

'3

'4

If (fld.Attributes And dbAutoIncrField) = 0& Then


strReturn = "Long Integer"
Else
strReturn = "AutoNumber"
End If
Case dbCurrency: strReturn = "Currency"
Case dbSingle: strReturn = "Single"

'5
'6

Case dbDouble: strReturn = "Double"

'7

Case dbDate: strReturn = "Date/Time"

'8

Case dbBinary: strReturn = "Binary"


Case dbText

' 9 (no interface)

'10

If (fld.Attributes And dbFixedField) = 0& Then


strReturn = "Text"
Else
strReturn = "Text (fixed width)"

'(no interface)

End If
Case dbLongBinary: strReturn = "OLE Object"
Case dbMemo

'11

'12

If (fld.Attributes And dbHyperlinkField) = 0& Then


strReturn = "Memo"
Else
strReturn = "Hyperlink"
End If
Case dbGUID: strReturn = "GUID"

'15

'Attached tables only: cannot create these in JET.


Case dbBigInt: strReturn = "Big Integer"

'16

Case dbVarBinary: strReturn = "VarBinary"


Case dbChar: strReturn = "Char"

'17

'18

Case dbNumeric: strReturn = "Numeric"

'19
'20

Case dbDecimal: strReturn = "Decimal"


Case dbFloat: strReturn = "Float"

'21

Case dbTime: strReturn = "Time"

'22

Case dbTimeStamp: strReturn = "Time Stamp"

'23

'Constants for complex types don't work prior to Access 2007 and later.
Case 101&: strReturn = "Attachment"
Case 102&: strReturn = "Complex Byte"

'dbAttachment
'dbComplexByte

Case 103&: strReturn = "Complex Integer" 'dbComplexInteger


Case 104&: strReturn = "Complex Long"

'dbComplexLong

Case 105&: strReturn = "Complex Single"

'dbComplexSingle

Case 106&: strReturn = "Complex Double"


Case 107&: strReturn = "Complex GUID"

'dbComplexDouble
'dbComplexGUID

Case 108&: strReturn = "Complex Decimal" 'dbComplexDecimal


Case 109&: strReturn = "Complex Text"

'dbComplexText

Case Else: strReturn = "Field type " & fld.Type & " unknown"
End Select

FieldTypeName = strReturn
End Function

DIRLISTBOX() FUNCTION
This article describes an old technique of filling a list box via a callback function.
In Access 2000 and later, there is a newer technique that is more efficient and flexible.
To use the callback function:
Create a new module, by clicking the Modules tab of the Database window, and clicking New.
Paste in the code below.
Check that Access understands the code by choosing Compile on the Debug menu.
Save the module with a name such as Module1.
Set the Row Source Type property of your list box to just:
DirListBox
Do not use the equal sign or function brackets, and leave the Row Source property blank.

The code
Function DirListBox (fld As Control, ID, row, col, code)
' Purpose: To read the contents of a directory into a ListBox.
' Usage:

Create a ListBox. Set its RowSourceType to "DirListBox"

' Parameters: The arguments are provided by Access itself.


' Notes:
'

You could read a FileSpec from an underlying form.


Error handling not shown. More than 512 files not handled.

Dim StrFileName As String


Static StrFiles(0 To 511) As String ' Array to hold File Names
Static IntCount As Integer

' Number of Files in list

Select Case code


Case 0

' Initialize

DirListBox = True

Case 1

' Open: load file names into array

DirListBox = Timer
StrFileName = Dir$("C:\") ' Read filespec from a form here???
Do While Len(StrFileName) > 0
StrFiles(IntCount) = StrFileName
StrFileName = Dir
IntCount = IntCount + 1
Loop

Case 3

' Rows

DirListBox = IntCount

Case 4

' Columns

DirListBox = 1

Case 5

' Column width in twips

DirListBox = 1440

Case 6

' Supply data

DirListBox = StrFiles(row)

End Select
End Function

PLAYSOUND() FUNCTION
To play a sound in any event, just set an event such as a form's OnOpen to:

=PlaySound("C:\WINDOWS\CHIMES.WAV")
Paste the declaration and function into a module, and save.
Use the 16-bit version for Access 1 and 2.
Note that these calls will not work with the 64-bit version of Office (as distinct from the 64-bit versions of
Windows.)

32-bit versions (Access 95 onwards):


Declare Function apisndPlaySound Lib "winmm" Alias "sndPlaySoundA" _
(ByVal filename As String, ByVal snd_async As Long) As Long

Function PlaySound(sWavFile As String)


' Purpose: Plays a sound.
' Argument: the full path and file name.

If apisndPlaySound(sWavFile, 1) = 0 Then
MsgBox "The Sound Did Not Play!"
End If
End Function

16-bit versions (Access 1 or 2):


Declare Function sndplaysound% Lib "mmsystem" (ByVal filename$, ByVal snd_async%)

Function PlaySound (msound)


Dim XX%
XX% = sndplaysound(msound, 1)
If XX% = 0 Then MsgBox "The Sound Did Not Play!"

End Function

PARSEWORD() FUNCTION
This function parses a word or item from a field or expression.
It is similar to the built-in Split() function, but extends its functionality to handle nulls, errors, finding the last
item, removing leading or doubled spacing, and so on.
It is particularly useful for importing data where expressions need to be split into different fields.
Use your own error logger, or copy the one in this link: LogError()

Examples
To get the second word from "My dog has fleas":
ParseWord("My dog has fleas", 2)
To get the last word from the FullName field:
ParseWord([FullName], -1)
To get the second item from a list separated by semicolons:
ParseWord("first;second;third;fourth;fifth", 2, ";")
To get the fourth sentence from the Notes field:
ParseWord([Notes], 4, ".")
To get the third word from the Address field, ignoring any doubled up spaces in the field:
ParseWord([Address], 3, ,True, True)

Arguments
varPhrase: the field or expression that contains the word you want.
iWordNum: which word: 1 for the first word, 2 for the second, etc. Use -1 to get the last word, -2 for the
second last, ...
strDelimiter: the character that separates the words. Assumed to be a space unless you specify otherwise.
bRemoveLeavingDelimiters: If True, any leading spaces are removed from the phrase before processing.
Defaults to False.
bIgnoreDoubleDelimiters: If True, any double-spaces inside the phrase are treated as a single space. Defaults to
False.

Return
The word from the string if found. Null for other cases, including the second word in this string, "Two spaces",
unless the last argument is True.

The code

Function ParseWord(varPhrase As Variant, ByVal iWordNum As Integer, Optional strDelimiter As


String = " ", _
Optional bRemoveLeadingDelimiters As Boolean, Optional bIgnoreDoubleDelimiters As Boolean)
As Variant
On Error GoTo Err_Handler
'Purpose: Return the iWordNum-th word from a phrase.
'Return: The word, or Null if not found.
'Arguments: varPhrase = the phrase to search.
'

iWordNum = 1 for first word, 2 for second, ...

'

Negative values for words form the right: -1 = last word; -2 = second last word, ...

'

(Entire phrase returned if iWordNum is zero.)

'

strDelimiter = the separator between words. Defaults to a space.

'

bRemoveLeadingDelimiters: If True, leading delimiters are stripped.

'
'
'

Otherwise the first word is returned as null.


bIgnoreDoubleDelimiters: If true, double-spaces are treated as one space.
Otherwise the word between spaces is returned as null.

'Author: Allen Browne. http://allenbrowne.com. June 2006.


Dim varArray As Variant

'The phrase is parsed into a variant array.

Dim strPhrase As String

'varPhrase converted to a string.

Dim strResult As String

'The result to be returned.

Dim lngLen As Long

'Length of the string.

Dim lngLenDelimiter As Long 'Length of the delimiter.


Dim bCancel As Boolean

'Flag to cancel this operation.

'*************************************
'Validate the arguments
'*************************************
'Cancel if the phrase (a variant) is error, null, or a zero-length string.

If IsError(varPhrase) Then
bCancel = True
Else
strPhrase = Nz(varPhrase, vbNullString)
If strPhrase = vbNullString Then
bCancel = True
End If
End If
'If word number is zero, return the whole thing and quit processing.
If iWordNum = 0 And Not bCancel Then
strResult = strPhrase
bCancel = True
End If
'Delimiter cannot be zero-length.
If Not bCancel Then
lngLenDelimiter = Len(strDelimiter)
If lngLenDelimiter = 0& Then
bCancel = True
End If
End If

'*************************************
'Process the string
'*************************************
If Not bCancel Then
strPhrase = varPhrase
'Remove leading delimiters?

If bRemoveLeadingDelimiters Then
strPhrase = Nz(varPhrase, vbNullString)
Do While Left$(strPhrase, lngLenDelimiter) = strDelimiter
strPhrase = Mid(strPhrase, lngLenDelimiter + 1&)
Loop
End If
'Ignore doubled-up delimiters?
If bIgnoreDoubleDelimiters Then
Do
lngLen = Len(strPhrase)
strPhrase = Replace(strPhrase, strDelimiter & strDelimiter, strDelimiter)
Loop Until Len(strPhrase) = lngLen
End If
'Cancel if there's no phrase left to work with
If Len(strPhrase) = 0& Then
bCancel = True
End If
End If

'*************************************
'Parse the word from the string.
'*************************************
If Not bCancel Then
varArray = Split(strPhrase, strDelimiter)
If UBound(varArray) >= 0 Then
If iWordNum > 0 Then

'Positive: count words from the left.

iWordNum = iWordNum - 1

'Adjust for zero-based array.

If iWordNum <= UBound(varArray) Then


strResult = varArray(iWordNum)
End If
Else

'Negative: count words from the right.

iWordNum = UBound(varArray) + iWordNum + 1


If iWordNum >= 0 Then
strResult = varArray(iWordNum)
End If
End If
End If
End If

'*************************************
'Return the result, or a null if it is a zero-length string.
'*************************************
If strResult <> vbNullString Then
ParseWord = strResult
Else
ParseWord = Null
End If

Exit_Handler:
Exit Function

Err_Handler:
Call LogError(Err.Number, Err.Description, "ParseWord()")
Resume Exit_Handler

End Function

How it works
The function accepts a Variant as the phrase, so you can use it where a field could be null (a field with no
value) or error (e.g. trying to parse a field on a report that has no records.) The first stage is to validate the
arguments before trying to use them.
The second stage is to pre-process the string to remove leading delimiters, or to ignore doubled-up delimiters
within the string, if the optional arguments indicate the user wants this.
The Split() function parses the phrase into an array of words. Since the array is zero-based, the word number
is adjusted by 1. If the word number is negative, we count down from the upper bound of the array. Note that
iWordNum is passed ByVal since we are changing its value within the procedure.
Finally we return the result string, or Null if the result is a zero-length string.

FILEEXISTS() AND FOLDEREXISTS() FUNCTIONS


Use these functions to determine whether a file or directory is accessible.
They are effectively wrappers for Dir() and GetAttr() respectively.
Searching an invalid network name can be slow.

FileExists()
This function returns True if there is a file with the name you pass in, even if it is a hidden or system file.
Assumes the current directory if you do not include a path.
Returns False if the file name is a folder, unless you pass True for the second argument.
Returns False for any error, e.g. invalid file name, permission denied, server not found.
Does not search subdirectories. To enumerate files in subfolders, see List files recursively.

FolderExists()
This function returns True if the string you supply is a directory.
Return False for any error: server down, invalid file name, permission denied, and so on.

TrailingSlash()
Use the TrailingSlash() function to add a slash to the end of a path unless it is already there.

Examples
Look for a file named MyFile.mdb in the Data folder:
FileExists("C:\Data\MyFile.mdb")
Look for a folder named System in the Windows folder on C: drive:
FolderExists("C:\Windows\System")
Look for a file named MyFile.txt on a network server:
FileExists("\\MyServer\MyPath\MyFile.txt")
Check for a file or folder name Wotsit on the server:
FileExists("\\MyServer\Wotsit", True)
Check the folder of the current database for a file named GetThis.xls:
FileExists(TrailingSlash(CurrentProject.Path) & "GetThis.xls")

The code

Function FileExists(ByVal strFile As String, Optional bFindFolders As Boolean) As Boolean


'Purpose: Return True if the file exists, even if it is hidden.
'Arguments: strFile: File name to look for. Current directory searched if no path included.

'

bFindFolders. If strFile is a folder, FileExists() returns False unless this argument is True.

'Note:

Does not look inside subdirectories for the file.

'Author: Allen Browne. http://allenbrowne.com June, 2006.


Dim lngAttributes As Long

'Include read-only files, hidden files, system files.


lngAttributes = (vbReadOnly Or vbHidden Or vbSystem)

If bFindFolders Then
lngAttributes = (lngAttributes Or vbDirectory) 'Include folders as well.
Else
'Strip any trailing slash, so Dir does not look inside the folder.
Do While Right$(strFile, 1) = "\"
strFile = Left$(strFile, Len(strFile) - 1)
Loop
End If

'If Dir() returns something, the file exists.


On Error Resume Next
FileExists = (Len(Dir(strFile, lngAttributes)) > 0)
End Function

Function FolderExists(strPath As String) As Boolean


On Error Resume Next
FolderExists = ((GetAttr(strPath) And vbDirectory) = vbDirectory)
End Function

Function TrailingSlash(varIn As Variant) As String


If Len(varIn) > 0 Then
If Right(varIn, 1) = "\" Then
TrailingSlash = varIn
Else
TrailingSlash = varIn & "\"
End If
End If
End Function

CLEARLIST() AND SELECTALL() FUNCTIONS


The ClearList() function deselects all items in a multi-select list box, or sets the value to Null if the list box is
not multi-select.
The SelectAll() function selects all the items in a multi-select list box. It has no effect is the list box is not
multi-select.
Use your own error logger, or copy the one in this link: LogError()

Examples:
To select all items in the list box named List0 on Form1:
Call SelectAll(Forms!Form1!List0)
To deselect them all:
Call ClearList(Forms!Form1!List0)

The code

Function ClearList(lst As ListBox) As Boolean


On Error GoTo Err_ClearList
'Purpose: Unselect all items in the listbox.
'Return: True if successful
'Author: Allen Browne. http://allenbrowne.com June, 2006.
Dim varItem As Variant

If lst.MultiSelect = 0 Then
lst = Null
Else
For Each varItem In lst.ItemsSelected
lst.Selected(varItem) = False
Next
End If

ClearList = True

Exit_ClearList:
Exit Function

Err_ClearList:
Call LogError(Err.Number, Err.Description, "ClearList()")
Resume Exit_ClearList
End Function

Public Function SelectAll(lst As ListBox) As Boolean


On Error GoTo Err_Handler
'Purpose: Select all items in the multi-select list box.
'Return: True if successful
'Author: Allen Browne. http://allenbrowne.com June, 2006.
Dim lngRow As Long

If lst.MultiSelect Then
For lngRow = 0 To lst.ListCount - 1
lst.Selected(lngRow) = True
Next
SelectAll = True
End If

Exit_Handler:
Exit Function

Err_Handler:

Call LogError(Err.Number, Err.Description, "SelectAll()")


Resume Exit_Handler
End Function

COUNT LINES (VBA CODE)


The code below returns the number of lines of code in the current database. It counts both the stand-alone
modules and the modules behind forms and reports. Optionally, you can list the number of lines in each
module, and/or give a summary of the number of each type of module and the line count for each type.
To use the code in your database, create a new module, and paste it in. Then:
Make sure your code is compiled (Compile on Debug menu) and saved (Save on File menu.)
Open the Immediate Window (Ctrl+G), and enter:
? CountLines()
January 2008 update: Added the optional lines to exclude the code in this module from the count.

Option Compare Database


Option Explicit
'Purpose: Count the number of lines of code in your database.
'Author: Allen Browne (allen@allenbrowne.com)
'Release: 26 November 2007
'Copyright: None. You may use this and modify it for any database you write.
'

All we ask is that you acknowledge the source (leave these comments in your code.)

'Documentation: http://allenbrowne.com/vba-CountLines.html

Private Const micVerboseSummary = 1


Private Const micVerboseListAll = 2

Public Function CountLines(Optional iVerboseLevel As Integer = 3) As Long


On Error GoTo Err_Handler
'Purpose: Count the number of lines of code in modules of current database.
'Requires: Access 2000 or later.
'Argument: This number is a bit field, indicating what should print to the Immediate Window:
'

0 displays nothing

'

1 displays a summary for the module type (form, report, stand-alone.)

'

2 list the lines in each module

'

3 displays the summary and the list of modules.

'Notes:

Code will error if dirty (i.e. the project is not compiled and saved.)

'

Just click Ok if a form/report is assigned to a non-existent printer.

'

Side effect: all modules behind forms and reports will be closed.

'

Code window will flash, since modules cannot be opened hidden.

Dim accObj As AccessObject 'Each module/form/report.


Dim strDoc As String

'Name of each form/report

Dim lngObjectCount As Long 'Number of modules/forms/reports


Dim lngObjectTotal As Long 'Total number of objects.
Dim lngLineCount As Long 'Number of lines for this object type.
Dim lngLineTotal As Long 'Total number of lines for all object types.
Dim bWasOpen As Boolean

'Flag to leave form/report open if it was open.

'Stand-alone modules.
lngObjectCount = 0&
lngLineCount = 0&
For Each accObj In CurrentProject.AllModules
'OPTIONAL: TO EXCLUDE THE CODE IN THIS MODULE FROM THE COUNT:
' a) Uncomment the If ... and End If lines (3 lines later), by removing the single-quote.
' b) Replace MODULE_NAME with the name of the module you saved this in (e.g. "Module1")
' c) Check that the code compiles after your changes (Compile on Debug menu.)
'If accObj.Name <> "MODULE_NAME" Then
lngObjectCount = lngObjectCount + 1&
lngLineCount = lngLineCount + GetModuleLines(accObj.Name, True, iVerboseLevel)
'End If

Next
lngLineTotal = lngLineTotal + lngLineCount
lngObjectTotal = lngObjectTotal + lngObjectCount
If (iVerboseLevel And micVerboseSummary) <> 0 Then
Debug.Print lngLineCount & " line(s) in " & lngObjectCount & " stand-alone module(s)"
Debug.Print
End If

'Modules behind forms.


lngObjectCount = 0&
lngLineCount = 0&
For Each accObj In CurrentProject.AllForms
strDoc = accObj.Name
bWasOpen = accObj.IsLoaded
If Not bWasOpen Then
DoCmd.OpenForm strDoc, acDesign, WindowMode:=acHidden
End If
If Forms(strDoc).HasModule Then
lngObjectCount = lngObjectCount + 1&
lngLineCount = lngLineCount + GetModuleLines("Form_" & strDoc, False, iVerboseLevel)
End If
If Not bWasOpen Then
DoCmd.Close acForm, strDoc, acSaveNo
End If
Next
lngLineTotal = lngLineTotal + lngLineCount

lngObjectTotal = lngObjectTotal + lngObjectCount


If (iVerboseLevel And micVerboseSummary) <> 0 Then
Debug.Print lngLineCount & " line(s) in " & lngObjectCount & " module(s) behind forms"
Debug.Print
End If

'Modules behind reports.


lngObjectCount = 0&
lngLineCount = 0&
For Each accObj In CurrentProject.AllReports
strDoc = accObj.Name
bWasOpen = accObj.IsLoaded
If Not bWasOpen Then
'In Access 2000, remove the ", WindowMode:=acHidden" from the next line.
DoCmd.OpenReport strDoc, acDesign, WindowMode:=acHidden
End If
If Reports(strDoc).HasModule Then
lngObjectCount = lngObjectCount + 1&
lngLineCount = lngLineCount + GetModuleLines("Report_" & strDoc, False, iVerboseLevel)
End If
If Not bWasOpen Then
DoCmd.Close acReport, strDoc, acSaveNo
End If
Next
lngLineTotal = lngLineTotal + lngLineCount
lngObjectTotal = lngObjectTotal + lngObjectCount
If (iVerboseLevel And micVerboseSummary) <> 0 Then

Debug.Print lngLineCount & " line(s) in " & lngObjectCount & " module(s) behind reports"
Debug.Print lngLineTotal & " line(s) in " & lngObjectTotal & " module(s)"
End If

CountLines = lngLineTotal

Exit_Handler:
Exit Function

Err_Handler:
Select Case Err.Number
Case 29068&

'This error actually occurs in GetModuleLines()

MsgBox "Cannot complete operation." & vbCrLf & "Make sure code is compiled and saved."
Case Else
MsgBox "Error " & Err.Number & ": " & Err.Description
End Select
Resume Exit_Handler
End Function

Private Function GetModuleLines(strModule As String, bIsStandAlone As Boolean, iVerboseLevel As


Integer) As Long
'Usage:

Called by CountLines().

'Note:

Do not use error handling: must pass error back to parent routine.

Dim bWasOpen As Boolean

'Flag applies to standalone modules only.

If bIsStandAlone Then
bWasOpen = CurrentProject.AllModules(strModule).IsLoaded
End If

If Not bWasOpen Then


DoCmd.OpenModule strModule
End If
If (iVerboseLevel And micVerboseListAll) <> 0 Then
Debug.Print Modules(strModule).CountOfLines, strModule
End If
GetModuleLines = Modules(strModule).CountOfLines
If Not bWasOpen Then
DoCmd.Close acModule, strModule, acSaveYes
End If
End Function

INSERT CHARACTERS AT THE CURSOR


Copy the function below into a standard module in your database. You can then call it from anywhere in your
database to insert characters into the active control, at the cursor position.
If any characters are selected at the time you run this code, those characters are overwritten. That is in
keeping with what normally happens in Windows programs.

Usage examples
Here are some examples of how the function could be used.

1. Simulate the tab character


In Access, the Tab key does not insert a tab character as it does in Word. To simulate this, you could insert 4
spaces when the user presses the Tab key.
Use the KeyDown event procedure of your text box, like this:

Private Sub txtMemo_KeyDown(KeyCode As Integer, Shift As Integer)


If (KeyCode = vbKeyTab) And (Shift = 0) Then
If InsertAtCursor(" ") Then
KeyCode = 0
End If
End If
End Sub

2. Insert boilerplate text


This example inserts preset paragraphs at the cursor as the user presses Alt+1, Alt+2, etc.

Private Sub Text0_KeyDown(KeyCode As Integer, Shift As Integer)


Dim strMsg As String
Dim strText As String

If Shift = acAltMask Then


Select Case KeyCode
Case vbKey1

strText = "Paragraph 1" & vbCrLf


Case vbKey2
strText = "Paragraph 2" & vbCrLf
Case vbKey3
strText = "Paragraph 3" & vbCrLf
'etc for other paragraphs.
End Select
If InsertAtCursor(strText, strMsg) Then
KeyCode = 0
ElseIf strMsg <> vbNullString Then
MsgBox strMsg, vbExclamation, "Problem inserting boilerplate text"
End If
End If
End Sub

3. Toolbar button
For a variation on the above, you could create a buttons on a custom toolbar/ribbon that insert the
paragraphs. You could allow the user to define their own paragraphs (stored in a table), and use DLookup() to
retrieve the values to insert.
Note that you cannot use a command button on the form to do this: when its Click event runs, it has focus, and
the attempt to insert text into the command button cannot succeed.

4. Insert today's date on a new line


In a memo field, you may want to insert a new line and today's date when Alt+D is pressed

Private Sub Text5_KeyDown(KeyCode As Integer, Shift As Integer)


If (Shift = acAltMask) And KeyCode = vbKeyD Then
Call InsertAtCursor(vbCrLf & Date)
End If
End Sub

The code
Here is the code to copy into a standard module in your database:

Public Function InsertAtCursor(strChars As String, Optional strErrMsg As String) As Boolean


On Error GoTo Err_Handler
'Purpose: Insert the characters at the cursor in the active control.
'Return: True if characters were inserted.
'Arguments: strChars = the character(s) you want inserted at the cursor.
'

strErrMsg = string to append any error messages to.

'Note:

Control must have focus.

Dim strPrior As String

'Text before the cursor.

Dim strAfter As String

'Text after the cursor.

Dim lngLen As Long

'Number of characters

Dim iSelStart As Integer 'Where cursor is.

If strChars <> vbNullString Then


With Screen.ActiveControl
If .Enabled And Not .Locked Then
lngLen = Len(.Text)
'SelStart can't cope with more than 32k characters.
If lngLen <= 32767& - Len(strChars) Then
'Remember characters before cursor.
iSelStart = .SelStart
If iSelStart > 1 Then
strPrior = Left$(.Text, iSelStart)
End If
'Remember characters after selection.

If iSelStart + .SelLength < lngLen Then


strAfter = Mid$(.Text, iSelStart + .SelLength + 1)
End If
'Assign prior characters, new ones, and later ones.
.Value = strPrior & strChars & strAfter
'Put the cursor back where it as, after the new ones.
.SelStart = iSelStart + Len(strChars)
'Return True on success
InsertAtCursor = True
End If
End If
End With
End If

Exit_Handler:
Exit Function

Err_Handler:
Debug.Print Err.Number, Err.Description
Select Case Err.Number
Case 438&, 2135&, 2144& 'Object doesn't support this property. Property is read-only. Wrong
data type.
strErrMsg = strErrMsg & "You cannot insert text here." & vbCrLf
Case 2474&, 2185&

'No active control. Control doesn't have focus.

strErrMsg = strErrMsg & "Cannot determine which control to insert the characters into." &
vbCrLf
Case Else
strErrMsg = strErrMsg & "Error " & Err.Number & ": " & Err.Description & vbCrLf

End Select
Resume Exit_Handler
End Function

HYPERLINKS: WARNINGS, SPECIAL CHARACTERS,


ERRORS
The GoHyperlink() function (below) performs the same task as FollowHyperlink(), with improved control over
the outcome. Like FollowHyperlink, you can use it to:
Open a browser to a webpage (http:// prefix)
Send an email (mailto: prefix)
Open a file, using the program registered to handle that type (Word for .doc, Notepad for .txt, or Paint for
.bmp, etc.)

Why a replacement?
FollowHyperlink can be frustrating:
Security warnings may block you, or warn you not to open the file (depending on file type, location, Windows
version, permissions, and policies.)
Files fail to open if their names contains some characters (such as # or %.)
Errors are generated if a link fails to open, so any routine that calls it must have similar error handling.
GoHyperlink addresses those frustrations:
It prepends "file:///" to avoid the most common security warnings.
It handles special characters more intelligently.
Errors are handled within the routine. Check the return value if you want to know if the link opened.
It cannot solve these issues completely:
If your network administrator will not allow hyperlinks to open at all, they will not open.
If a file name contains two # characters, it will be understood as a hyperlink. Similarly, if a file name contains
the % character followed by two valid hexadecimal digits (e.g. Studetn%50.txt), it will be be interpreted as a
pre-escaped character rather than three literal characters.
These are limitations relating to HTML. But you will experience these issues far less frequently than with
FollowHyperlink, which fowls up whenever it finds one of these sequences.

Using GoHyperlink()
To use GoHyperlink() in your database:
Create a new stand-alone module in your database. Open the code window (Ctrl+G), and the New
Module button

on the toolbar (2nd from left on Standard toolbar.)

Paste in the code below.

To verify Access understands it, choose Compile on the Debug menu.


Save the module, with a name such as ajbHyperlink.
You can now use GoHyperlink() anywhere in your database.
For example if you have a form with a hyperlink field named MyHyperlink, use:
Call GoHyperlink(Me.[MyHyperlink])
To open a file, be sure you pass in the full path. If necessary, use:
Call GoHyperlink(CurDir & "\MyDoc.doc")
The PrepareHyperlink() function can also be used to massage a file name so it will be handled correctly as a
hyperlink.

The code

Option Compare Database


Option Explicit
'Purpose: Avoid warning and error messages when opening files with FollowHyperlink
'Author: Allen Browne (allen@allenbrowne.com)
'Release: 28 January 2008
'Usage:
'
'
'

To open MyFile.doc in Word, use:


GoHyperlink "MyFile.doc"

instead of:
FollowHyperlink "MyFile.doc"

'Rationale:
'FollowHyperlink has several problems:
' a) It errors if a file name contains characters such as #, %, or &.
' b) It can give unwanted warnings, e.g. on a fileame with "file:///" prefix.
' c) It yields errors if the link did not open.
'This replacement:
' a) escapes the problem characters
' b) prepends the prefix
' c) returns True if the link opened (with an optional error message if you care.)

'Limitations:
' - If a file name contains two # characters, it is treated as a hyperlink.
' - If a file name contains % followed by 2 hex digits, it assumes it is pre-escaped.
' - File name must include path.
'Documentation: http://allenbrowne.com/func-GoHyperlink.html

Public Function GoHyperlink(FullFilenameOrLink As Variant) As Boolean


On Error GoTo Err_Handler
'Purpose: Replacement for FollowHyperlink.
'Return: True if the hyperlink opened.
'Argument: varIn = the link to open
Dim strLink As String
Dim strErrMsg As String

'Skip error, null, or zero-length string.


If Not IsError(FullFilenameOrLink) Then
If FullFilenameOrLink <> vbNullString Then
strLink = PrepHyperlink(FullFilenameOrLink, strErrMsg)
If strLink <> vbNullString Then
FollowHyperlink strLink
'Return True if we got here without error.
GoHyperlink = True
End If
'Display any error message from preparing the link.
If strErrMsg <> vbNullString Then
MsgBox strErrMsg, vbExclamation, "PrepHyperlink()"
End If

End If
End If

Exit_Handler:
Exit Function

Err_Handler:
MsgBox "Error " & Err.Number & ": " & Err.Description, vbExclamation, "GoHyperlink()"
Resume Exit_Handler
End Function
Public Function PrepHyperlink(varIn As Variant, Optional strErrMsg As String) As Variant
On Error GoTo Err_Handler
'Purpose: Avoid errors and warnings when opening hyperlinks.
'Return: The massaged link/file name.
'Arguments: varIn
'
'Note:
'

= the link/file name to massage.

strErrMsg = string to append error messages to.


Called by GoHyperlink() above.
Can also be called directly, to prepare hyperlinks.
'File name or address

Dim strAddress As String


Dim strDisplay As String
Dim strTail As String
Dim lngPos1 As Long

'Display part of hyperlink (if provided)


'Any remainding part of hyperlink after address
'Position of character in string (and next)

Dim lngPos2 As Long


Dim bIsHyperlink As Boolean

'Flag if input is a hyperlink (not just a file name.)

Const strcDelimiter = "#"

'Delimiter character within hyperlinks.

Const strcEscChar = "%"

'Escape character for hyperlinks.

Const strcPrefix As String = "file:///" 'Hyperlink type if not supplied.

If Not IsError(varIn) Then


strAddress = Nz(varIn, vbNullString)
End If

If strAddress <> vbNullString Then


'Treat as a hyperlink if there are two or more # characters (other than together, or at the end.)
lngPos1 = InStr(strAddress, strcDelimiter)
If (lngPos1 > 0&) And (lngPos1 < Len(strAddress) - 2&) Then
lngPos2 = InStr(lngPos1 + 1&, strAddress, strcDelimiter)
End If
If lngPos2 > lngPos1 + 1& Then
bIsHyperlink = True
strTail = Mid$(strAddress, lngPos2 + 1&)
strDisplay = Left$(strAddress, lngPos1 - 1&)
strAddress = Mid$(strAddress, lngPos1 + 1&, lngPos2 - lngPos1)
End If

'Replace any % that is not immediately followed by 2 hex digits (in both display and address.)
strAddress = EscChar(strAddress, strcEscChar)
strDisplay = EscChar(strDisplay, strcEscChar)
'Replace special characters with percent sign and hex value (address only.)
strAddress = EscHex(strAddress, strcEscChar, "&", """", " ", "#", "<", ">", "|", "*", "?")
'Replace backslash with forward slash (address only.)
strAddress = Replace(strAddress, "\", "/")
'Add prefix if address doesn't have one.
If Not ((varIn Like "*://*") Or (varIn Like "mailto:*")) Then

strAddress = strcPrefix & strAddress


End If
End If

'Assign return value.


If strAddress <> vbNullString Then
If bIsHyperlink Then
PrepHyperlink = strDisplay & strcDelimiter & strAddress & strcDelimiter & strTail
Else
PrepHyperlink = strAddress
End If
Else
PrepHyperlink = Null
End If

Exit_Handler:
Exit Function

Err_Handler:
strErrMsg = strErrMsg & "Error " & Err.Number & ": " & Err.Description & vbCrLf
Resume Exit_Handler
End Function

Private Function EscChar(ByVal strIn As String, strEscChar As String) As String


'Purpose: If the escape character is found in the string,
'

escape it (unless it is followed by 2 hex digits.)

'Return: Fixed up string.

'Arguments: strIn
'

= the string to fix up

strEscChar = the single character used for escape sequqnces. (% for hyperlinks.)

Dim strOut As String

'output string.

Dim strChar As String

'character being considered.

Dim strTestHex As String


Dim lngLen As Long
Dim i As Long

'4-character string of the form &HFF.


'Length of input string.

'Loop controller

Dim bReplace As Boolean

'Flag to replace character.

lngLen = Len(strIn)
If (lngLen > 0&) And (Len(strEscChar) = 1&) Then
For i = 1& To lngLen
bReplace = False
strChar = Mid(strIn, i, 1&)
If strChar = strEscChar Then
strTestHex = "&H" & Mid(strIn, i + 1&, 2&)
If Len(strTestHex) = 4& Then
If Not IsNumeric(strTestHex) Then
bReplace = True
End If
End If
End If
If bReplace Then
strOut = strOut & strEscChar & Hex(Asc(strEscChar))
Else
strOut = strOut & strChar
End If

Next
End If

If strOut <> vbNullString Then


EscChar = strOut
ElseIf lngLen > 0& Then
EscChar = strIn
End If
End Function

Private Function EscHex(ByVal strIn As String, strEscChar As String, ParamArray varChars()) As String
'Purpose: Replace any characters from the array with the escape character and their hex value.
'Return: Fixed up string.
'Arguments: strIn

= string to fix up.

'

strEscChar = the single character used for escape sequqnces. (% for hyperlinks.)

'

varChars() = an array of single-character strings to replace.

Dim i As Long

'Loop controller

If (strIn <> vbNullString) And IsArray(varChars) Then


For i = LBound(varChars) To UBound(varChars)
strIn = Replace(strIn, varChars(i), strEscChar & Hex(Asc(varChars(i))))
Next
End If
EscHex = strIn
End Function

INTELLIGENT HANDLING OF DATES AT THE START OF A


CALENDAR YEAR
Did you know 80% of this year's dates can be entered with 4 keystrokes or less? Jan 1 is just 1/1 (or 1 1).
Access automatically supplies the current year. Good data entry operators regularly enter dates like this.
But this comes unstuck during the first quarter of a new year, when you are entering dates from the last
quarter of last year. It is January, and you type 12/12. Access interprets it as 11 months in the future, when it is
much more likely to be the month just gone.
The code below changes that, so if you enter a date from the final calendar quarter but do not specify a year, it
is interpreted as last year. But it does so only if today is in the first quarter of a new year.

How to use
To use this in your database:
Copy the function:
In your database, open the code window (e.g. press Ctrl+G.)
On the Insert menu, choose Module. Access opens a new module.
Paste in the code below.
To ensure Access understands it, choose Compile on the Debug menu.
Save the module with a name such as ajbAdjustDateForYear.
Apply to a text box:
Open your form in design view.
Right-click the text box and choose Properties.
In the Properties box, set After Update to:
=AdjustDateForYear([Text0])
substituting your text box name for Text0.
Repeat step 2 for other your text boxes.
If the After Update property of your text box is already set to:
[Event Procedure]
click the Build button (...) beside this property. Access opens the code window. In the AfterUpdate procedure,
insert this line (substituting your text box name for Text0):
Call AdjustDateForYear(Me.Text0)
Optional: If you want to warn the user when an entry will be adjusted, set bConfirm to True instead of False.
As offered, no warning is given, as the goal is to speed up good data entry operators. The way it behaves is
analogous to the way Access handles dates when the century is not specified.

Limitations

As supplied, the code works only with text boxes (not combos), and only in countries where the date delimiter
is slash (/) or dash (-). Other delimiter characters such as dot (.) are not handled.
The code makes no changes if you enter a time as well as a date.
For unbound text boxes, the code does nothing if it does not recognize your entry as a date. Setting the Format
property of the unbound text box to General Date can help Access understand that you intend a date.

The function

Public Function AdjustDateForYear(txt As TextBox, Optional bConfirm As Boolean = False) As


Boolean
On Error GoTo Err_Handler
'Purpose: Adjust the text box value for change of year.
'

If the user entered Oct-Dec *without* a year, and it's now Jan-Mar, _
Access will think it's this year when it's probably last year.

'Arguments: txt:
'

the text box to examine.

bConfirm: set this to True if you want a confirmation dialog.

'Return: True if the value was changed.


'Usage:
'

For a text box named Text0, set it's After Update property to:
=AdjustDateForYear([Text0])

'

Or in code use:

'

Call AdjustDateForYear(Me.Text0)

'Note:

Makes no chanage if the user specifies a year, or includes a time.

Dim dt As Date

'Value of the text box

Dim strText As String

'The Text property of the text box.

Dim lngLen As Long

'Length of string.

Dim bSuppress As Boolean 'Flag to suppress the change (user answered No.)
Const strcDateDelim = "/" 'Delimiter character for dates.

With txt
'Only if the value is Oct/Nov/Dec, today is Jan/Feb/Mar, and the year is the same.

If IsDate(.Value) Then
dt = .Value
If (Month(dt) >= 10) And (Month(Date) <= 3) And (Year(dt) = Year(Date)) Then
'Get the Text in the text box, without leading/trailing spaces, _
and change dash to the date delimiter.
strText = Replace$(Trim$(.Text), "-", strcDateDelim)

'Change multiple spaces to one, then to the date delimiter.


Do
lngLen = Len(strText)
strText = Replace$(strText, " ", " ")
Loop Until Len(strText) = lngLen
strText = Replace$(strText, " ", strcDateDelim)

'Subtract a year if only ONE delimiter appears in the Text (i.e. no year.)
If Len(strText) - Len(Replace$(strText, strcDateDelim, vbNullString)) = 1& Then
dt = DateAdd("yyyy", -1, dt)
If bConfirm Then
strText = "Did you intend:" & vbCrLf & vbTab & Format$(dt, "General Date")
If MsgBox(strText, vbYesNo, "Adjust date for year?") = vbNo Then
bSuppress = True
End If
End If
If Not bSuppress Then
.Value = dt
End If
AdjustDateForYear = True

End If
End If
End If
End With

Exit_Handler:
Exit Function

Err_Handler:
If Err.Number <> 2185& Then

'Text box doesn't have focus, so no Text property.

MsgBox "Error " & Err.Number & ": " & Err.Description, vbExclamation, "AdjustDateForYear"
'Call LogError(Err.Number, Err.Description, ".AdjustDateForYear")
End If
Resume Exit_Handler
End Function

How it works
We only make a change if:
the text box contains a date (so is not null)
today is in the first quarter of the year (i.e. the Month() of the Date is 3 or less)
the value of the text box is October or later of the current year
the user did not specify a year.
The first two IF statements deal with (a), (b), and (c), but (d) requires a bit more effort. As well as
its Value property, a text box has a Text property that exposes the actual characters in the box. The Text
property will contain only one delimiter (/) if there is no year.
In practice, Access lets you use the slash (/), dash (-), space, or even multiple spaces as a delimiter between the
different parts of the date. The code therefore strips multiple spaces back to one, and substitutes the slash for
any dash or space. It then examines the length of the text, compared to the length of the text if you remove
the delimiters. If the difference is 1, the user entered only one delimiter, so they did not specify a year. We
have now evaluated (d).

If the bConfirm argument tells us to give a warning, we pop up the MsgBox() to get confirmation. Finally, if all
these conditions are met, we assign a Value to the text box that is one year less, and return True to indicate a
change was made.
The error handler silently suppresses error 2185. If the code runs when another control has focus on its form,
the attempt to read the Text property will fail. Normally this could not happen: a control's AfterUpdate event
cannot fire unless it has focus. But it could occur if you programmatically call its AfterUpdate event procedure.
The alternative error handler line is provided (commented out) in case you want to use our error logger.

Keep something open


To prevent users modifying the database schema, developers normally hide the Navigation Pane/Database
Window, and use a form as the interface to everything in the application. We will refer to this form as the
"switchboard", whether you created it yourself or via the wizard.
When the user closes everything else, we want the switchboard to open automatically. The code below does
that. Just set one property for each form and report.
Note that the code does not run when if the user closes the switchboard itself. If you try that, closing the
database is a nightmare (worse in some versions than others.) Every time you close the switchboard, you open
it again, so you can't get out. So, provided the switchboard is not the last thing closed, the code below will
open it.

To use this in your database:


In your database, open the code window (Ctrl+G.)
Insert a new standard module (Module on the Insert menu.)
Paste the code below into this window.
Replace the word frmSwitchboard with the name of your switchboard form (about a dozen lines from the
bottom.)
To verify that Access understands the code, choose Compile on the Debug menu.
Save the module with a name such as ajbKeep1Open.
For each form in your database (except the switchboard), set its On Close property to:
=Keep1Open([Form])
For each report in your database, set its On Close property to:
=Keep1Open([Report])

Notes:
Don't substitute the name of your form or report above. Literally type [Form] or [Report] including the
square brackets.
You don't need to set the Close property for subforms or subreports.
If the On Close property is set to [Event Procedure], click the Build button (...) beside the property.
Access opens the code window. In the close event, add the line:
Call Keep1Open(Me)
Grab the error logger code if you wish to use that line in the error handler, rather than the MsgBox.
The code is not designed to distinguish multiple instances of the same form. Test the hWnd as well if you need
to handle that.
Do NOT set the Close property for your switchboard.

If it bothers you that the switchboard does not reopen itself every time you close it, you could create a macro
named AutoKeys, and define a hotkey. The example below opens a form named frmSwitchboard when you
press F12 anywhere in the database.

The code

Option Compare Database


Option Explicit
Public Function Keep1Open(objMe As Object)
On Error GoTo Err_Keep1Open
'Purpose: Open the Switchboard if nothing else is visible.
'Argument: The object being closed.
'Usage:

In the OnClose property of forms and reports:

'

=Keep1Open([Form])

'

=Keep1Open([Report])

'Note:

Replace "Switchboard" with the name of your switchboard form.

Dim frm As Form

'an open form.

Dim rpt As Report

'an open report.

Dim bFound As Boolean 'Flag not to open the switchboard.

'Any other visible forms?


If Not bFound Then
For Each frm In Forms

If (frm.hWnd <> objMe.hWnd) And (frm.Visible) Then


bFound = True
Exit For
End If
Next
End If

'Any other visible reports?


If Not bFound Then
For Each rpt In Reports
If (rpt.hWnd <> objMe.hWnd) And (rpt.Visible) Then
bFound = True
Exit For
End If
Next
End If

'If none found, open the switchboard.


If Not bFound Then
DoCmd.OpenForm "Switchboard"
End If

Exit_Keep1Open:
Set frm = Nothing
Set rpt = Nothing
Exit Function

Err_Keep1Open:
If Err.Number <> 2046& Then

'OpenForm is not available when closing database.

'Call LogError(Err.Number, Err.Description, ".Keep1Open()")


MsgBox "Error " & Err.Number & ": " & Err.Description, vbExclamation, "Keep1Open()"
End If
End Function

How it works
You can use the code without learning how it works, as the only change you need to make is to substitute the
name of your switchboard form.
Normally, you should use the narrowest data type you can: Form rather than Object, Textbox rather
than Control, etc. This function accepts an Object, so we can use it with both forms and reports.
The Forms collection lists the open forms, so we loop through this list. As soon as we find an open form that is
visible and is not ObjMe (the form being closed), we set the bFound flag to true, and skip the rest of the forms
(as we know we don't need to open the switchboard.)
The form that called Keep1Open() will be in the Forms collection, but we want to ignore it and see if
any other visible forms are open. You may be tempted to use:
If frm.Name <> obj.Name Then
But examining the Name is not good enough. It fails if:
A form and a report have the same name, or
Multiple instances of the same form are open (since they have the same name.)
We could have used:
If Not frm Is objMe Then
but old versions of Access (97) do not always handle Is correctly.
The safest solution is to test the hWnd property. This is a unique number assigned by Windows so it can
manage the form. Since no two windows can have the same hWnd at the same time, we completely avoid the
issue of duplicate names.
If we did not find any other visible form open, we do exactly the same thing with the Reports collection, so see
if any other visible report is open.
Finally, if no other visible form or report was found, we open the switchboard.
The error handler suppresses error 2046, which can occur if it tries to open the switchboard when you are
trying to close the database. (The ampersand is a type declaration character, indicating the literal 2046 is a
Long.)

SPLASH SCREEN WITH VERSION INFORMATION


Note: This code will not work with the 64-bit version of Office. (It does work with 64-bit Windows.)
To showcase your database, you want a nifty screen that splashes color at start-up. You need this screen to
give version details also, so you can show it through About this Program(typically on the Help menu.)
The screen will show the software name, intellectual property rights, and your contact details just in case
someone needs support. When they do call for support, it can help if this screen shows their Access setup and
version details.
It splashes on screen for 2 seconds when your database loads. If you open it from a button or menu, click on
the form to close it.
Download the sample database (zipped) for Access 2000 and later or Access 97. You can also view
the code for this utility.

Click an element for details

Copy to your database


Open your database, and import:
module ajbVersion,
form frmHelpAbout, and
macro AutoExec.
In Access 97 - 2003, use Import on the File menu.
In Access 2007 and 2010, the External Data tab of the ribbon handles imports.

Open the module in design view to verify Access understands it.


In the code window, choose Compile on the Debug menu.
(In Access 2000 or 2002, you may need to set references.)
When the splash screen closes initially, it opens another one.
If your next form is not named Switchboard, change the name in the code.
For example, change the line:
Const strcNextForm = "Switchboard"
to:
Const strcNextForm = "Form1"
If you do not want another screen to open, use:
Const strcNextForm = ""
Open the form in design view.
If you have attached tables, replace "Test1" with the name of your table. Otherwise use:
=GetDataPath("")
Change the Caption of the labels to show your software name, copyright, and developer details.
(Optional.) Set the form's Picture property to whatever you want.

Why show these details?


This table summarizes the information displayed on the splash screen, and why you want to show these
details:
Caption

Control Source

Description

Purpose

Version:

="1.00"

Whatever you want.

Indicates if the user has your latest


version.

MS Access:

=GetAccessVersion()

Version of
msaccess.exe

Indicates if Office service packs are


needed.

File Format:

=GetFileFormat()

db format, e.g. 97,


2002/3, accdb

Helps identify version-specific


problems.

JET/ACE:

=GetJetVersion()

Version of the query


engine

Indicates if JET service packs are


needed.

JET User:

=CurrentUser()

User name (Access


security)

Helps identify problems with Access


Permissions.

=GetNetworkUserName()

User name
(Windows)

Helps identify problems with


Windows permissions. Useful for
logging.

Win User:

Workstation:

=GetMachineName()

Computer name

Helps identify corruptions from faulty


hardware. Useful for logging.

Data File:

=GetDataPath("Table1")

Location of back end


database

Indicates if the front end is connected


to the right data file.

Version and Data File are the only ones you need to change. Let's see what each one tells you.

Your Version
This is a number you manually increment each time you modify the database, and distribute a version to your
users.
GetAccessVersion()
MS Access Service Pack Version
97

SR-2

8.0.0.5903

2000

SP-3

9.0.0.6620

2002

SP-3

10.0.6501.0

2003

SP-3

11.0.8166.0

2007

SP-3

12.0.6607.1000

2010

14.0.4760.1000

MS Access Version
This number indicates the version of msaccess.exe. The major number (e.g. 12.0) indicates the office version.
The minor number (e.g. 6423.1000) indicates what service pack has been applied. The number may be higher
than shown at right if you apply a hotfix, such asService Pack 2 for Access 2007, or kb945674 for Access
2003.
Service packs are available from http://support.microsoft.com/sp. Office 97 is no longer supported, but
you may get the patch here.
(Note: These are the version numbers of msaccess.exe, not the Office numbers shown under Help | About.
The Access 2010 numbers seem unstable at release time.)

File Format
GetFileFormat()
Access 97

Access 2000

Access 2002

Access 2003

2007 & 2010

97 MDB

2000 MDB

2000 MDB

2000 MDB

2000 MDB

97 MDE

2000 MDE
2000 ADP
2000 ADE

2000 MDE
2000 ADP
2000 ADE
2002/3 MDB
2002/3 MDE
2002/3 ADP
2002/3 ADE

2000 MDE
2000 ADP
2000 ADE
2002/3 MDB
2002/3 MDE
2002/3 ADP
2002/3 ADE

2000 MDE
2000 ADP
2000 ADE
2002/3 MDB
2002/3 MDE
2002/3 ADP
2002/3 ADE
2007 ACCDB
2007 ACCDE
2007 ACCDR
2007 ACCDT

This text box indicates what file format your database is using. If the database is split, it refers to the front
end.
Access 97 has two possible formats:
MDB (Microsoft Database),
MDE (compiled-only database.)
Access 2000 uses a different format MDB and MDE, and added:
ADP (Access Database Project, using SQL Server tables),
ADE (compiled-only project.)
Access 2002 introduced its own file storage format, but supports the Access 2000 ones as well.
Access 2003 used the 2002 format (now called 2002/3), retaining support for the 2000 formats.
Access 2007 and 2010 support all eight 2000 and 2002/3 formats, plus four new ones:
ACCDB (database based on the new ACE engine), and
ACCDE (compiled-only ACE database.)
ACCDR (ACCDB or ACCDE limited to runtime)
ACCDT (database template)
Note: Even though Access 2010 uses the 2007 ACCD* file format, you will no longer be able to use the tables in
Access 2007 if you add calculated fields to them.
(Note: descriptions may not be correct for versions beyond Access 2010.)

GetJetVersion()

JET/ACE Version
JET (Joint Engine Technology) is the data engine Access uses for its tables and queries. Different versions of
Access use different versions of JET, and Microsoft supplies the JET service packs for JET separately from the
Office service packs.

Access 97 uses JET 3.5 (msjet35.dll). A fully patched version of Access 97 should show version 3.51.3328.0.
Microsoft no longer supports Access 97, so it can be difficult to get service packs.
Access 2000, 2002 and 2003 use JET 4 (msjet40.dll.) They should show at least 4.0.8618.0. The minor version
may start with 9 (depending on your version of Windows), but if it is less than 8, it is crucial to download SP8
for JET 4 from http://support.microsoft.com/kb/239114. The issue is not only that older versions have
unfixed bugs, but that you are likely tocorrupt a database if computers with different versions of JET use it at
the same time.
Access 2007 uses a private version of JET call the the Access Data Engine (acecore.dll), with a major version of
12. Since this version is private to Office, we expect it to be maintained by the Office 2007 service packs, and
not require separate maintenance.

JET User
This displays the name the user logged into the database with. If you are not using Access security, it will be
the default user, Admin. If you have secured the database, knowing the user name may help you track down
problems related to limited user permissions.
The CurrentUser() function is built into Access, so no API call is needed.
GetNetworkUserName()

Windows User
This displays the Windows user name (see User Accounts in the Windows Control Panel.) It can help in tracing
a problem related to the user's limited permissions under Windows. You can also call GetNetworkUserName()
in your database to log user activity.
We use the API call, as it is possible to fudge the value of Environ("username").
GetMachineName()

Workstation
This displays the name of the computer, as shown on the network. Corruption of the database is usually
associated with the interrupted write (see Preventing corruption), so logging users in and out of the
database with GetMachineName() can help to identify the machine that is crashing and corrupting the
database.
GetDataPath()

Data File
Use this with a split database, to indicate what file this front end is attached to. Occasionally you may get
users who attached to the wrong database (such as a backup.)
Specify the name of an attached table in place of "Table1." If you do not have an attached table matching the
name you used, you see #Error. To suppress this option if you have no attached tables, use a zero-length
string, i.e.:
=GetDataPath("")

Note that the screen reports what data file is expected, whether found or not. For example, the sample
database has a table named Test1 that it expects to find in C:\Data\junk.mdb. You probably have no such file,
but the splash screen still indicates what data file it is looking for - useful if the user cannot tell you what data
file they used previously.

Conclusion
That should help you to look good, and give good support for the databases you develop.

Popup Calendar

There are plenty of ActiveX control calendars, but they have issues with versioning, broken references, and
handling Nulls. This is an ordinary Access form you can import into any database.
Download the zip file (30 KB) for Access 2000 and later or Access 97.
In Access 2007 and laber, there's a popup calendar built in, so this form is not needed (though it does work.)
Just set the Show Date Picker property of the text box to "For dates."

Adding the calendar to your database


To use the calendar:
Import form frmCalendar and module ajbCalendar into your database.
Copy the calendar button from the sample form onto your form. (There are two styles to choose from.)
Set the On Click property of the button to something like this:
=CalendarFor([SaleDate], "Select the sale date")
Change SaleDate to the name of your date text box. The quoted text is optional, for the title bar of the
calendar.
When you click the button, it pops up the calendar, reading the date from your text box. Select a date and
click Ok, or double-click a date to write it back to your text box and close the calendar. Click Cancel to close
the calendar without changing the date.
The command button that opens the calendar is tied to a particular text box, so there is no problem with
adding multiple command buttons and reusing the popup calendar if your form needs several dates.

Keyboard shortcuts
Left

prior day

Right

next day

Up

prior week

Down

next week

Home

first of month

End

last of month

Pg Up

prior month

Pg Dn

next month

Tips for using the calendar


There are no restrictions on how you use this calendar. You may freely use it in any database you develop, for
personal or commercial purposes.
Pause the mouse over the question mark to see the list of keyboard shortcuts. Alt+T is the hotkey for today's
date.
A "Type Mismatch" message (Error 13) will occur if your text box contains a non-date value. To avoid this:
If your text box is unbound, set its Format property to a date format (e.g. Short Date), so Access knows it is a
date.
Do not assign a non-date value (such as a zero-length-string) to your text box. Use Null instead, e.g.:
Me.SaleDate = Null
do not use a non-date value in the Default Value of your text box.
As supplied, the calendar works only with text boxes, not combos.
For other alternatives, Stephen Lebans has a wrapper class for the Microsoft Month Calendar Common
Control. Tony Toews and Jeff Conrad list other options.

PRINTER SELECTION UTILITY


You can design a report to use one of your printers by choosing Page Setup from the Page Setup ribbon
(Access 2010), the Report Tools ribbon (Access 2007), or the File menu (previous versions.) But that approach
is useless if others use your report: you do not know what printers they will have installed.
This utility lets the end user assign one of their printers to each report. Whenever they open the report, it is
sent to that printer. The utility works with MDE files and runtimeversions also.
The utility also illustrates how to manipulate the Printer object and the Printers collection introduced in
Access 2002.
Click to download the utility (30KB, Access 2002/3 mdb format, zipped). You can also view the code from this
utility.

Overview
The utility has a function named OpenTheReport() to use instead of DoCmd.OpenReport. The function checks
to see if the user has assigned a particular printer for the report, and assigns the Printer before the report
opens.
To assign a printer, all the user has to do is preview the report, and click the custom Set Printer toolbar button.
The utility remembers the choice, and uses that printer for that report in future.

Limitations
The utility works only with Access 2002 and later. Albert Kallal has one for earlier versions: Access
97 or Access 2000.
The utility does not let the user choose paper sizes. That can be done by opening the report in design view,
and manipulating PrtMip. (Does not work for MDE.)

To use the correct printer, you must use the supplied function, OpenTheReport(). Docmd.OpenReport works
if the report is previewed, but not if it is opened straight to print. Opening reports with the New keyword (for
multiple instances) is not supported.
If you open several reports directly to print at once, the timing of the assignment of the Printer object may not
operate correctly. To avoid this, program a delay between printing the reports, and include DoEvents.

Copying the utility into your database


To import the components of this utility into your database:
Unzip the file.
Open your database.
In Access 2007 and later, click the Access icon in the Import group of the External Data ribbon.
In previous versions, choose File | Get External | Import.
Choose PrinterMgt.mdb as the file to import from.
In the Import Objects dialog, click the Options button, and check the box Menus and Toolbars.
On the Forms tab of the dialog, click frmSetPrinter.
On the Modules tab, click ajbPrinter.
Click Ok.
The other form (frmSwitchboardExample) and the reports are included purely for demonstration purposes.
To prepare your report to use this utility, open it in design view, and set these properties of the Report:
On Close

=SetupPrinter4Report()

On Activate

=SetupPrinter4Report([Report].[Name])

On Deactivate

=SetupPrinter4Report()

Toolbar

ReportToolbar

If you will use this with most of your reports, you may wish to set up a default report.
In Access 2002, you must also include a reference to the Microsoft DAO 3.6 Library, by choosing References on
the Tools menu from a code window. More information on references.

Syntax of OpenTheReport()
OpenTheReport() is the only function you need to learn. It is similar to the OpenReport method built into
Access, but has several enhancements.
Firstly, it looks to see if you have specified a printer to use for the report, and assigns it before opening the
report. (If you use the old OpenReport to print directly (no preview), it will not use the desired printer.)
Secondly, this function defaults to preview instead of printing directly.

Thirdly, it avoids the problem where the report is not filtered correctly if it is already open.
Fourthly, it does away with the FilterName argument that is rarely used, confusing, and inconsistent. Instead,
it provides a simple way to pass a description of the filter to display on the report. Without an explanation of
the filter, the printed report is meaningless, and printing the filter itself on the report may be too cryptic. You
can therefore enter a description string. It is passed to the report through its OpenArgs. To display the
description on the report, just add a text box with Control Source of: =[Report].[OpenArgs]
Fifthly, it avoids the need to trap Error 2501 in every procedure where you open a report. OpenTheReport()
traps and discards this annoying error message that can come from the report's NoData event, an impatient
user, or some other problem. If you need to know whether the report opened, check the return value of the
function. It will be True on success, or False if the report did not open.

Examples:
Code

Explanation

Call OpenTheReport("Report1")

Open Report1 in preview.

Call OpenTheReport("Report2", acViewNormal, _


"ClientID = 5", "Barney's orders only.")

Send Report2 to the printer, filtered to


client 5, with a description to print on
the report.

If Not OpenTheReport("Report5") Then


MsgBox "Report did not open."
End If

Show a message if the report was


cancelled.

Argument reference:
OpenTheReport() takes these arguments:
strDoc - Name of the report to open.
lngView - Optional. Use acViewPreview to open in preview (default), or acViewNormal to go straight to
print.
strWhere - Optional. A Where Condition to filter the report.
strDescrip - Optional. A description you want to show on your report. (Passed via OpenArgs.)
lngWindowMode - Optional. Use acWindowNormal for normal window (default), or acDialog for dialog
mode.
Note that strDoc, strWhere, and strDescrip are strings - not variants.

The boring technical stuff


When you assign a printer for a report, the utility creates a new property named Printer2Use. The property is
type dbText, and stores the name of the user's printer. The property is created on the report document:
CurrentDb().Containers("Reports").Documents("MyReport")
since that property can be written and read without opening the report itself. The property is deleted if the

user returns the report to the default printer. One advantage of using the custom property over a lookup table
is that the assigned printer remains with the report even if the report is renamed, or duplicated.
It turns out that you cannot merely set the Printer object in the Open event of the report. That works if the
report is previewed, but not if it is sent straight to print. This is the reason the report must be opened through
the function that sets the printer before the report is opened.
The Printer setting is application-wide. If you have several reports open in preview at once, and switch
between them, the utility needs to assign the correct printer to each one. The Activate and Deactivate events
of the report achieve that.
To restore the Printer object, to the Windows default, use:
Set Application.Printer = Nothing.
That destroys the object. Access then reconstructs it - from the default Windows printer.
View the code if you wish.

Code for Printer Selection Utility


The code in this article is explained in the Printer Selection Utility.

'Author: Allen J Browne, 2004. allen@allenbrowne.com


'Versions: Access 2002 or later. (Uses Printer object.)

'Limitations: 1. May not work where multiple reports sent directly to print, without pause.
'
'

2. Reports must be opened using the OpenTheReport() function,


so the printer is set *before* the report is opened.

'Methodology: Creates a custom property of the report document.


'

The specified printer is therefore retained even if the report is renamed or copied.

'Explanation of this utility at: http://allenbrowne.com/AppPrintMgt.html

Option Compare Database


Option Explicit

Private Const mstrcPropName = "Printer2Use" 'Name of custom property assigned to the report
document.
Private Const conMod = "basPrinter"

'Name of this module. Used by error handler.

'Use this function as a replacement for OpenReport.

Function OpenTheReport(strDoc As String, _


Optional lngView As AcView = acViewPreview, _
Optional strWhere As String, _
Optional strDescrip As String, _
Optional lngWindowMode As AcWindowMode = acWindowNormal) As Boolean
On Error GoTo Err_Handler
'Purpose: Wrapper for opening reports.
'Arguments: View = acViewPreview or acViewNormal. Defaults to preview.
'

strWhere = WhereCondition. Passed to OpenReport.

'

strDescrip = description of WhereCondition (passed as OpenArgs).

'

WindowMode = acWindowNormal or acDialog. Defaults to normal.

'Return: True if opened.


'Notes:
'

1. Filter propery of OpenReport is not supported.

2. Suppresses error 2501 if report cancelled.

Dim bCancel As Boolean


Dim strErrMsg As String

'If the report is alreay open, close it so filtering is handled correctly.


If CurrentProject.AllReports(strDoc).IsLoaded Then
DoCmd.Close acReport, strDoc, acSaveNo
End If

'Set the printer for this report (if custom property defined).
strErrMsg = vbNullString
Call SetupPrinter4Report(strDoc, strErrMsg)
If Len(strErrMsg) > 0 Then
strErrMsg = strErrMsg & vbCrLf & "Continue anyway?"

If MsgBox(strErrMsg, vbYesNo + vbDefaultButton2, "Warning") <> vbYes Then


bCancel = True
End If
End If

'Open the report


If Not bCancel Then
DoCmd.OpenReport strDoc, lngView, , strWhere, lngWindowMode, strDescrip
OpenTheReport = True
End If

Exit_Handler:
Exit Function

Err_Handler:
Select Case Err.Number
Case 2501& 'Cancelled.
'do nothing
Case 2467& 'Bad report name.
MsgBox "No report named: " & strDoc, vbExclamation, "Cannot open report."
Case Else
Call LogError(Err.Number, Err.Description, conMod & ".OpenTheReport")
End Select
Resume Exit_Handler
End Function
Public Function SetupPrinter4Report(Optional strDoc As String, Optional strErrMsg As String) As
String
On Error GoTo Err_Handler

'Purpose: Set the application printer to the one specified for the report.
'Argument: Name of the report to prepare for. Omit to restore default printer.
'

strErrMsg = message string to append problems to.

'Return: Name of the printer assigned, if successful.


'Usage:
'

In On Activate property of report:


=SetupPrinter4Report([Report].[Name])

'

In On Deactivate and On Close properties of report:

'

=SetupPrinter4Report()

Dim strPrinterName As String

If Len(strDoc) > 0 Then


strPrinterName = GetPrinter4Report(strDoc, strErrMsg)
End If
'Passing zero-length string restores default printer.
If UsePrinter(strPrinterName, strErrMsg) Then
SetupPrinter4Report = strPrinterName
End If

Exit_Handler:
Exit Function

Err_Handler:
Call LogError(Err.Number, Err.Description, conMod & ".SetupPrinter4Report")
Resume Exit_Handler
End Function
Public Function AssignReportPrinter(strDoc As String, strPrinterName As String) As Boolean
On Error GoTo Err_Handler

'Purpose: Set or remove a custom property for the report for a particular printer.
'Arguments: strDoc = name or report.
'

strPrinterName = name of printer. Zero-length string to remove property.

'Return: True on success.


Dim db As DAO.Database
Dim doc As DAO.Document
Dim strMsg As String

'Error message.

Dim bReturn As Boolean

'Get a reference to the report document.


Set db = CurrentDb()
Set doc = db.Containers("Reports").Documents(strDoc)

If Len(strPrinterName) = 0 Then
'Remove the property (if it exists).
If HasProperty(doc, mstrcPropName) Then
doc.Properties.Delete mstrcPropName
End If
bReturn = True
Else
'Create or set the property.
If SetPropertyDAO(doc, mstrcPropName, dbText, strPrinterName, strMsg) Then
bReturn = True
Else
MsgBox strMsg, vbInformation, "Printer not set for report: " & strDoc
End If
End If

AssignReportPrinter = bReturn

Exit_Handler:
Set doc = Nothing
Set db = Nothing
Exit Function

Err_Handler:
Call LogError(Err.Number, Err.Description, conMod & ".AssignReportPrinter")
Resume Exit_Handler
End Function
Public Function OpenFormSetPrinter()
On Error GoTo Err_Handler
'Purpose: Open the form for setting the printer of the report on screen.
'Usage:

Called from macMenu.SetPrinter.

Dim strReport As String

strReport = Screen.ActiveReport.Name 'Fails if no report active.


DoCmd.OpenForm "frmSetPrinter", WindowMode:=acDialog, OpenArgs:=strReport

Exit_Handler:
Exit Function

Err_Handler:
Select Case Err.Number
Case 2501& 'OpenForm was cancelled.

'do nothing
Case 2476&
MsgBox "You must have a report active on screen to set a printer for it.", _
vbExclamation, "Cannot set printer for report"
Case Else
Call LogError(Err.Number, Err.Description, conMod & ".OpenFormSetPrinter")
End Select
Resume Exit_Handler
End Function
Public Function GetPrinter4Report(strDoc As String, Optional strErrMsg As String) As String
On Error GoTo Err_Handler
'Purpose: Get the custom printer to use with the report.
'Argument: Name of the report to find the printer for.
'Return: Name of printer. Zero-length string if none specified, or printer no longer installed.
Dim strPrinter As String
Dim prn As Printer

'Get the name of the custom printer for the report. Error if none assigned.
strPrinter = CurrentDb().Containers("Reports").Documents(strDoc).Properties(mstrcPropName)

If Len(strPrinter) > 0 Then


'Check that this printer still exists. Error if printer no longer exists.
Set prn = Application.Printers(strPrinter)
'Return the printer name.
GetPrinter4Report = strPrinter
End If

Exit_Handler:
Set prn = Nothing
Exit Function

Err_Handler:
Select Case Err.Number
Case 3270& 'Property not found.
'do nothing: means use the default printer.
Case 5&

'No such printer.

strErrMsg = strErrMsg & "Custom printer not found: " & strPrinter & vbCrLf & _
"Default printer will be used." & vbCrLf
Case Else
Call LogError(Err.Number, Err.Description, conMod & ".GetPrinter4Report")
End Select
Resume Exit_Handler
End Function
Public Function UsePrinter(strPrinter As String, strErrMsg As String) As Boolean
On Error GoTo Err_Handler
'Purpose: Make the named printer the active one.
'Arguments: Name of printer to assign. If zero-length string, restore default.
'

Error message string to append to.

'Return: True if set (or already set).

'If no printer specified, restore the default (by unsetting).


If Len(strPrinter) = 0 Then
Set Application.Printer = Nothing
Else

'Do nothing if printer is already set.


If Application.Printer.DeviceName = strPrinter Then
'do nothing
Else
Set Application.Printer = Application.Printers(strPrinter)
End If
End If
UsePrinter = True

Exit_Handler:
Exit Function

Err_Handler:
Select Case Err.Number
Case 5 'Invalid printer.
strErrMsg = strErrMsg & "Invalid printer: " & strPrinter & vbCrLf
Case Else
Call LogError(Err.Number, Err.Description, conMod & ".UsePrinter")
End Select
Resume Exit_Handler
End Function
'-----------------------------------------------------------------------------------------------'You may prefer to replace this with a true error logger. See http://allenbrowne.com/ser-23a.html
Function LogError(lngErrNum As Long, strErrDescrip As String, _
strCallingRoutine As String, Optional bShowUser As Boolean = True)
Dim strMsg As String

If bShowUser Then
strMsg = "Error " & lngErrNum & " - " & strErrDescrip
MsgBox strMsg, vbExclamation, strCallingRoutine
End If
End Function
Function SetPropertyDAO(obj As Object, strPropertyName As String, intType As Integer, _
varValue As Variant, Optional strErrMsg As String) As Boolean
On Error GoTo ErrHandler
'Purpose: Set a property for an object, creating if necessary.
'Arguments: obj = the object whose property should be set.
'

strPropertyName = the name of the property to set.

'

intType = the type of property (needed for creating)

'

varValue = the value to set this property to.

'

strErrMsg = string to append any error message to.

If HasProperty(obj, strPropertyName) Then


obj.Properties(strPropertyName) = varValue
Else
obj.Properties.Append obj.CreateProperty(strPropertyName, intType, varValue)
End If
SetPropertyDAO = True

ExitHandler:
Exit Function

ErrHandler:
strErrMsg = strErrMsg & obj.Name & "." & strPropertyName & " not set to " & _

varValue & ". Error " & Err.Number & " - " & Err.Description & vbCrLf
Resume ExitHandler
End Function
Public Function HasProperty(obj As Object, strPropName As String) As Boolean
'Purpose: Return true if the object has the property.
Dim varDummy As Variant

On Error Resume Next


varDummy = obj.Properties(strPropName)
HasProperty = (Err.Number = 0)
End Function

Das könnte Ihnen auch gefallen