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.
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.
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:
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
Phone
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.
Exactly 8 characters
Exactly 4 digits
Not Null
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.)
Is Null OR IN (1, 2, 4, 8)
Yes/No/Null field
Is Null OR 0 or -1
To do this ...
A booking cannot end before it
starts
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.
Lookups:
Input Mask:
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.
AutoNumber
Surname
Text
FirstName
Text
AutoNumber
Sport Text
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.)
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.
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
Table/Query
Row Source
tblCustomers
Column Count
Column Widths
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
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
cboShowCat
tblProductCategory
[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
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:
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.
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
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;
In code, you can also use Me and Parent to shorten the references.
'
=MakePercent([Text23])
Exit_Handler:
Exit Function
Err_Handler:
If Err.Number <> 2185 Then
has focus.
MsgBox "Error " & Err.Number & " - " & Err.Description
End If
Resume Exit_Handler
End Function
You can disable the mouse wheel in Form view, and scroll records in Datasheet and Continuous
view.
'Return:
1 if moved forward a record, -1 if moved back a
record, 0 if not moved.
'Author:
'Usage:
'
Exit_Handler:
Exit Function
Err_Handler:
Select Case Err.Number
Case 2046&
last, etc.
Beep
Case 3314&, 2101&, 2115&
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.
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
'
no records.
'Note:
cannot use:
'
[Forms].[Form1].[Recordset].[RecordCount]
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.
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.
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.
'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.
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.
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
If Me.Dirty Then
Me.Dirty = False
End If
Else
strWhere = "[ID] = " & Me.[ID]
DoCmd.OpenReport "MyReport", acViewPreview, , strWhere
End If
End Sub
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.
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
=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:
Exit_Form_Current:
Set rst = Nothing
Exit Sub
Err_Form_Current:
If Err = 3021 Then
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
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.
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:
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:
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] & "'")
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.
(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:
(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
'
'
avarExceptionList: Names of the controls NOT to
lock (variant array of strings).
'Usage:
'Loop controller.
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 Else
'Includes acBoundObjectFrame, acCustomControl
Debug.Print ctl.Name & " not handled " & Now()
End Select
Next
Exit_Handler:
Set ctl = Nothing
Exit Function
Err_Handler:
MsgBox "Error " & Err.Number & " - " & Err.Description
Resume Exit_Handler
End Function
? 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.
? 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.
"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:
=[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)
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.
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:
The expression:
Solution
Use the IsNull() function:
If IsNull([Surname]) Then
(a)
(b)
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 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.
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
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
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.
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:
No
No
Width
6"
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
Font Name
MS Sans Serif
Allow AutoCorrect
No
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 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]
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:
'Caption of report.
strCaption = rpt.Caption
If strCaption = vbNullString Then
strCaption = rpt.Name
End If
DoCmd.CancelEvent
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.
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:
?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!
'
'
'
'
'
'Description:
'
'
'
'Inputs:
Interval:
'
Date1:
'
Date2:
'
ShowZero:
'
'Outputs:
On error: Null
'
'
'
'
selected.
'
'
be a negative value.
'
intervals
'
but
'
'
'
it
'
'
'
"ym",
'
'
dtTemp = Date1
Date1 = Date2
Date2 = dtTemp
booSwapped = True
End If
Diff2Dates = Null
varTemp = Null
If booCalcMonths Then
lngDiffMonths = Abs(DateDiff("m", Date1, Date2)) - _
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 booSwapped Then
varTemp = "-" & varTemp
End If
Diff2Dates = Trim$(varTemp)
End_Diff2Dates:
Exit Function
Err_Diff2Dates:
Resume End_Diff2Dates
End Function
'************** Code End *****************
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.
Case -6 To -2
result = Abs(days) & " days ago"
End Select
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
ElapsedTime = result
ElapsedTime_Exit:
Exit Function
ElapsedTime_Error:
MsgBox "Error " & Err.Number & ": " & Err.Description, _
vbCritical, "ElapsedTime"
Resume ElapsedTime_Exit
End Function
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
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"
You must
double-up the
quote
character
inside quotes.
Here is a "word"
="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.)
The dialog addresses four problem areas. This article explains each one, and how to solve them.
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.
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.
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 $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.)
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.
'Purpose:
seconds
'
e.g.
'
'
'
'Return:
passed in.
'Note:
'
RoundTime = Null
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
.Update
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.
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:
The code
Here is the code for the generic module (Step 2 above.)
on.
'
messages to.
strErrMsg
'
avarExceptionList = list of control names NOT to
copy values over to.
'Return:
'Usage:
In a form's BeforeInsert event, excluding
Surname and City controls:
'
Dim rs As DAO.Recordset
'Clone of form.
'ControlSource property.
'Loop counter.
'Initialize.
strForm = frm.Name
strActiveControl = frm.ActiveControl.Name
lngLBound = LBound(avarExceptionList)
lngUBound = UBound(avarExceptionList)
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
strExpr = fld.Properties("Expression")
If strExpr <> vbNullString Then
IsCalcTableField = True
End If
ExitHandler:
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
Unbound controls
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.
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:
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:
'Instances of frmClient.
Function OpenAClient()
'Purpose:
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
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:
'Object in clnClient
KeyAscii = 0
Screen.ActiveControl = Screen.ActiveControl + 1
Case 45
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?
Description
![Variable] = "CustomerIDLast"
![Value] = Me.CustomerID
![Description] = "Last customerID, for form
" & Me.Name
.Update
Else
.Edit
primary key.
![Value] = Me.CustomerID
.Update
End If
End With
rs.Close
End If
Set rs = Nothing
End Sub
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 = """"
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
' Usage:
'
' Note:
also.
Exit_LimitKeyPress:
Exit Sub
Err_LimitKeyPress:
Call LogError(Err.Number, Err.Description,
"LimitKeyPress()")
Resume Exit_LimitKeyPress
End Sub
' Usage:
'
' Note:
event also.
Exit_LimitChange:
Exit Sub
Err_LimitChange:
Call LogError(Err.Number, Err.Description, "LimitChange()")
Resume Exit_LimitChange
End Sub
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:
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.
Setting
Comment
Tag:
UsualBackColor=13684991
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.)
Suburb
RowSource
BoundColumn
ColumnCount
Step 1: Paste this into the General Declarations section of your form?s module:
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 ":
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:
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
End If
End Sub
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)
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.)
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:
.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
If CurrentProject.AllForms(strcCallingForm).IsLoaded Then
Set cbo = Forms(strcCallingForm)!CustomerID
cbo.Requery
End If
Exit_Handler:
Exit Sub
Err_Handler:
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
Value List
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.
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
Table/Query
Row Source
Column Count
Column Widths
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
http://allenbrowne.com
'Selected items
'Description of WhereCondition
'Length of string
'strDelim = """"
type. See note 1.
'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.
MsgBox "Error " & Err.Number & " - " & Err.Description,
, "cmdPreview_Click"
End If
Resume Exit_Handler
End Sub
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
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
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.
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.
'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
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
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
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
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.
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.
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.
fld.ForeignName = "CategoryID"
from the related table.
'Matching field
rel.Fields.Append fld
the relation's Fields collection.
db.Relations.Append rel
to the database.
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.)
Data Type
Description
* * * WARNING * * *
Text
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.
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.
Query
Form
-32768
Report
-32764
Module
-32761
cmdOpenReport_ClickErr:
Select Case Err.Number
Case 2501
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:
box.
' Usage:
EnumReports
'
' Notes:
automatically.
' Author:
Feb.'97.
Allen Browne
allen@allenbrowne.com
' Number of
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.
Next
EnumReports = True
Case acLBOpen
EnumReports = Timer
unique identifier.
Case acLBGetRowCount
' Return a
EnumReports = iRptCount
Case acLBGetColumnCount
EnumReports = 1
' 1 column
Case acLBGetColumnWidth
' 2 inches
EnumReports = 2 * 1440
Case acLBGetValue
name from the array.
EnumReports = sRptName(row)
Case acLBEnd
Erase sRptName
' Deallocate
array.
iRptCount = 0
End Select
End Function
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:
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
Alt+0251
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.
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.
A4
0.5"
Top margin
0.5"
2.666"
2.888"
1"
2.666"
1"
1"
2.888"
1"
2.666"
(Group Footer suppressed)
0.5"
Bottom margin
0.5"
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
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
Now select the Detail Section's OnPrint, and enter this code without the line numbers:
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.
End Sub
Set up the fields dLastLeft and sItem in any module in the general section:
The code
The outer OrderID Footer's Format event code looks like this:
odd.
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.
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
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%
Rate
0.00
0.05
10.00
.10
20.00
.12
50.00
.13
(SELECT Rate
FROM RateTable RT1
WHERE From =
(SELECT MAX(Minimum)
FROM
RateTable RT2
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.
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
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
Uniqueness
Format property
UNION query
resulting in truncation.
Concatenated fields
Row Source
Note that the same issues apply to expression that are longer than 255 characters, where Access must process
the expressions.
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:
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
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.
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.
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]
It generates fields named Amt and the month number, and Qty and the month number:
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
Subquery examples
The best way to grasp subqueries is to look at examples of how to use them.
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.
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.)
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
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.
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.
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.
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
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 may need to write the query results to a temporary table so you can use them efficiently.
Data Type
Description
CustomerID
Primary key
TotalValue
Currency
BeatenBy
the ranking
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
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.)
GROUP BY ClientID;
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.
Function Reconnect ()
'**************************************************************
'*
START YOUR APPLICATION (MACRO: AUTOEXEC) WITH THIS
FUNCTION
'*
AND THIS PROGRAM WILL CHANGE THE CONNECTIONS
AUTOMATICALLY
'*
'*
'*
'*
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)
'*************************************************************
'*
'*************************************************************
Next
'*************************************************************
'*
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;
JET (Interface)
DDL (Queries)
TEXT (size)
Text
[4]
[1]
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]
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
' Arguments:
'
Dim db As DAO.Database
Dim i As Integer
lNum = lNum - 1
value.
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
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:
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
Exit Function
End If
checkcompat = True
End Function
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
Sub ResetSerial()
Dim DB as DataBase
Set DB = DBengine(0)(0)
DB.properties!SerialNo = 0
End Sub
4 Exit_SomeName:
Exit Sub|Function
6 Err_SomeName:
7
Resume Exit_SomeName
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
Resume Next
Case 999
Resume Exit_SomeName
Case Else
Data Type
Description
ErrorLogID
AutoNumber
Primary Key.
ErrNumber
Number
ErrDescription
Text
ErrDate
Date/Time
CallingProc
Text
UserName
Text
Name of User.
ShowUser
Yes/No
Parameters
Text
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.
' Cancelled
'Do nothing.
Case 3314, 2101, 2115
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.
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.
'
'Author:
'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:
Dim db As DAO.Database
'This database.
Dim rs As DAO.Recordset
find.
'SQL statement.
'Length of string.
'Separator between items in
'Initialize to null.
varResult = Null
'dbAttachment
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")
? ECount("Region", "Customers")
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
'Arguments: Expr
= name of the field to count. Use
square brackets if the name contains a space.
'
Domain
'
Criteria
'
bCountDistinct = True to return the number of
distinct values in the field. Omit for normal count.
'Notes:
not.)
'
'
too.
ECount = Null
Set db = DBEngine(0)(0)
If bCountDistinct Then
'Count distinct values.
If Expr <> "*" Then
with the wildcard.
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.
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
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
The code
'Arguments: strExpr
'
strDomain
'
'
dblTop
= TOP number of records to average.
Ignored if zero or negative.
'
than 1.
'
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.
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
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).
Dim db As DAO.Database
'MsgBox message.
Exit_DoArchive:
'Step 5: Clean up
On Error Resume Next
Set db = Nothing
If bInTrans Then
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
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:
'
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
'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
Exit_Handler:
Exit Function
Err_Handler:
MsgBox "Error " & Err.Number & ": " & Err.Description
Resume Exit_Handler
End Function
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
End Function
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.
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:
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.
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.
'Purpose:
into VBA code.
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
Order Dates
Acme Corporation
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.
'
values.
'
values.
'
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
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
'Loop controller.
varMin = Null
'Initialize to null
Else
varMin = varValues(i)
End If
End If
Next
MinOfList = varMin
End Function
'Loop controller.
varMax = Null
'Initialize to null
MaxOfList = varMax
End Function
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:
Age = Null
'Initialize to Null
'Validate parameters
If IsDate(varDOB) Then
dtDOB = varDOB
from.
dtAsOf = Date
Else
dtAsOf = varAsOf
End If
" &
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:
' 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
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
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
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
Set db = CurrentDb()
Set tdf = db.TableDefs(strTableName)
Debug.Print "FIELD NAME", "FIELD TYPE", "SIZE", "DESCRIPTION"
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
'1
'2
'3
'4
'5
'6
'7
'8
'10
'(no interface)
End If
Case dbLongBinary: strReturn = "OLE Object"
Case dbMemo
'11
'12
'15
'16
'17
'18
'19
'20
'21
'22
'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
'dbComplexLong
'dbComplexSingle
'dbComplexDouble
'dbComplexGUID
'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:
' Initialize
DirListBox = True
Case 1
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
DirListBox = 1440
Case 6
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.)
If apisndPlaySound(sWavFile, 1) = 0 Then
MsgBox "The Sound Did Not Play!"
End If
End Function
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
'
Negative values for words form the right: -1 = last word; -2 = second last word, ...
'
'
'
'
'
'
'*************************************
'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
iWordNum = iWordNum - 1
'*************************************
'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()
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
'
bFindFolders. If strFile is a folder, FileExists() returns False unless this argument is True.
'Note:
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
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
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
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:
All we ask is that you acknowledge the source (leave these comments in your code.)
'Documentation: http://allenbrowne.com/vba-CountLines.html
0 displays nothing
'
'
'
'Notes:
Code will error if dirty (i.e. the project is not compiled and saved.)
'
'
Side effect: all modules behind forms and reports will be closed.
'
'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
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&
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
Called by CountLines().
'Note:
Do not use error handling: must pass error back to parent routine.
If bIsStandAlone Then
bWasOpen = CurrentProject.AllModules(strModule).IsLoaded
End If
Usage examples
Here are some examples of how the function could be used.
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.
The code
Here is the code to copy into a standard module in your database:
'Note:
'Number of characters
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&
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
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
The code
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
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:
'
'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
Exit_Handler:
Exit Function
Err_Handler:
strErrMsg = strErrMsg & "Error " & Err.Number & ": " & Err.Description & vbCrLf
Resume Exit_Handler
End Function
'Arguments: strIn
'
strEscChar = the single character used for escape sequqnces. (% for hyperlinks.)
'output string.
'Loop controller
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
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
'
strEscChar = the single character used for escape sequqnces. (% for hyperlinks.)
'
Dim i As Long
'Loop controller
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
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:
'
For a text box named Text0, set it's After Update property to:
=AdjustDateForYear([Text0])
'
Or in code use:
'
Call AdjustDateForYear(Me.Text0)
'Note:
Dim dt As Date
'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)
'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
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.
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
'
=Keep1Open([Form])
'
=Keep1Open([Report])
'Note:
Exit_Keep1Open:
Set frm = Nothing
Set rpt = Nothing
Exit Function
Err_Keep1Open:
If Err.Number <> 2046& Then
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.)
Control Source
Description
Purpose
Version:
="1.00"
MS Access:
=GetAccessVersion()
Version of
msaccess.exe
File Format:
=GetFileFormat()
JET/ACE:
=GetJetVersion()
JET User:
=CurrentUser()
=GetNetworkUserName()
User name
(Windows)
Win User:
Workstation:
=GetMachineName()
Computer name
Data File:
=GetDataPath("Table1")
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
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."
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
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.
=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")
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.
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.
'Limitations: 1. May not work where multiple reports sent directly to print, without pause.
'
'
The specified printer is therefore retained even if the report is renamed or copied.
Private Const mstrcPropName = "Printer2Use" 'Name of custom property assigned to the report
document.
Private Const conMod = "basPrinter"
'
'
'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?"
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.
'
'
'
=SetupPrinter4Report()
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.
'
'Error message.
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:
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)
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&
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.
'
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.
'
'
'
'
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