Download as DOCX, PDF, TXT or read online from Scribd
Download as docx, pdf, or txt
You are on page 1of 321
CONTENT
Useful Web Resources ............................................................................................................................ 5
What are Database objects? ................................................................................................................... 6 The Query Lost My Records! .................................................................... Error! Bookmark not defined. Common Errors with Null ........................................................................ Error! Bookmark not defined. Calculated Fields ..................................................................................................................................... 8 Relationships between Tables .............................................................................................................. 10 Validation Rules .................................................................................................................................... 12 When to use validation rules ................................................................................................................ 14 Don't use Yes/No fields to store preferences ....................................................................................... 15 Using a Combo Box to Find Records ..................................................................................................... 18 Referring to Controls on a Subform ...................................................................................................... 24 Enter value as a percent ....................................................................................................................... 25 Assigning Auto Keys for Aligning Controls ............................................... Error! Bookmark not defined. Why does my form go completely blank? ............................................... Error! Bookmark not defined. Scroll records with the mouse wheel .................................................................................................... 27 Avoid #Error in form/report with no records ....................................................................................... 30 Limiting a Report to a Date Range ........................................................................................................ 32 Print the record in the form .................................................................................................................. 36 Bring the total from a subreport back onto the main report ............................................................... 38 Numbering Entries in a Report or Form ................................................................................................ 40 Getting a value from a table: DLookup()............................................................................................... 45 Locking bound controls ......................................................................................................................... 47 Nulls: Do I need them? .......................................................................................................................... 52 Common Errors with Null ..................................................................................................................... 54 Problem properties ............................................................................................................................... 58 Default forms, reports and databases .................................................................................................. 63 Calculating elapsed time ....................................................................................................................... 67 A More Complete DateDiff Function .................................................................................................... 68 Constructing Modern Time Elapsed Strings in Access .......................................................................... 77 Quotation marks within quotes ............................................................................................................ 85 Why can't I append some records? ...................................................................................................... 87 Rounding in Access ............................................................................................................................... 89 Assign default values from the last record ........................................................................................... 96 Managing Multiple Instances of a Form ............................................................................................. 104 Rolling dates by pressing "+" or "-" ..................................................................................................... 108 Return to the same record next time form is opened ........................................................................ 109 Unbound text box: limiting entry length ............................................................................................ 112 Properties at Runtime: Forms ............................................................................................................. 116 Highlight the required fields, or the control that has focus ............................................................... 118 Combos with Tens of Thousands of Records ...................................................................................... 121 Adding values to lookup tables ........................................................................................................... 125 Use a multi-select list box to filter a report ........................................................................................ 132 Print a Quantity of a Label .................................................................................................................. 136 Has the record been printed? ............................................................................................................. 138 Code accompanying article: Has the record been printed? ...................................................... 139 Cascade to Null Relations .................................................................................................................... 144 List Box of Available Reports ............................................................................................................... 150 Format check boxes in reports ........................................................................................................... 154 Sorting report records at runtime....................................................................................................... 156 Reports: Page Totals ........................................................................................................................... 159 Reports: a blank line every fifth record .............................................................................................. 160 Reports: Snaking Column Headers ...................................................................................................... 162 Duplex reports: start groups on an odd page ..................................................................................... 164 Lookup a value in a range ................................................................................................................... 166 Action queries: suppressing dialogs, while knowing results ............................................................... 169 Truncation of Memo fields ................................................................................................................. 171 Crosstab query techniques ................................................................................................................. 173 Subquery basics .................................................................................................................................. 177 Ranking or numbering records ........................................................................................................... 183 Common query hurdles ...................................................................................................................... 187 Reconnect Attached tables on Start-up .............................................................................................. 192 Self Joins .............................................................................................................................................. 194 Field type reference - names and values for DDL, DAO, and ADOX ................................................... 196 Set AutoNumbers to start from ... ...................................................................................................... 198 Custom Database Properties .............................................................................................................. 201 Error Handling in VBA ......................................................................................................................... 206 Extended DLookup()............................................................................................................................ 210 Extended DCount() .............................................................................................................................. 215 Extended DAvg() ................................................................................................................................. 219 Archive: Move Records to Another Table ........................................................................................... 223 List files recursively ............................................................................................................................. 226 Enabling/Disabling controls, based on User Security ......................................................................... 231 Concatenate values from related records .......................................................................................... 236 MinOfList() and MaxOfList() functions ................................................................................................ 241 Age() Function ..................................................................................................................................... 244 TableInfo() function ............................................................................................................................ 252 DirListBox() function ........................................................................................................................... 256 PlaySound() function ........................................................................................................................... 258 ParseWord() function .......................................................................................................................... 260 FileExists() and FolderExists() functions .............................................................................................. 266 ClearList() and SelectAll() functions .................................................................................................... 269 Count lines (VBA code) ....................................................................................................................... 272 Insert characters at the cursor ............................................................................................................ 278 Hyperlinks: warnings, special characters, errors ................................................................................ 283 Intelligent handling of dates at the start of a calendar year .............................................................. 291 Splash screen with version information ............................................................................................. 300 Printer Selection Utility ....................................................................................................................... 307
USEFUL WEB RESOURCES www.allenbrowne.com
WHAT ARE DATABASE OBJECTS?
When you create a database, Access offers you Tables, Queries, Forms, Reports, Macros, and Modules. Here's a quick overview of what these are and when to use them. Tables All data is stored in tables. When you create a new table, Access asks you define fields (column headings), giving each a unique name, and telling Access the data type. You can use the "Text" type for most data, including numbers that don't need to be added e.g. phone numbers or postal codes. Once you have defined a table's structure, you can enter data. Each new row that you add to the table is called a record.
Queries Use a query to find or operate on the data in your tables. With a query, you can display the records that match certain criteria (e.g. all the members called "Barry"), sort the data as you please (e.g. by Surname), and even combine data from different tables. You can edit the data displayed in a query (in most cases), and the data in the underlying table will change. Special queries can also be defined to make wholesale changes to your data, e.g. delete all members whose subscriptions are 2 years overdue, or set a "State" field to "WA" wherever postcode begins with 6.
Forms These are screens for displaying data from and inputting data into your tables. The basic form has an appearance similar to an index card: it shows only one record at a time, with a different field on each line. If you want to control how the records are sorted, define a query first, and then create a form based on the query. If you have defined a one-to-many relationship between two tables, use the "Subform" Wizard to create a form which contains another form. The subform will then display only the records matching the one on the main form.
Reports If forms are for input, then reports are for output. Anything you plan to print deserves a report, whether it is a list of names and addresses, a financial summary for a period, or a set of mailing labels.
Macros An Access Macro is a script for doing a job. For example, to create a button which opens a report, you could use a macro which fires off the "OpenReport" action. Macros can also be used to set one field based on the value of another (the "SetValue" action), to validate that certain conditions are met before a record saved (the "CancelEvent" action) etc.
Modules This is where you write your own functions and programs if you want to. Everything that can be done in a macro can also be done in a module. Modules are far more powerful, and are essential if you plan to write code for a multi- user environment.
CALCULATED FIELDS How do you get Access to store the result of a calculation? For example, if you have fields named Quantity and UnitPrice, how do you get Access to write Quantity * UnitPrice to another field called Amount?
CALCULATIONS IN QUERIES Calculated columns are part of life on a spreadsheet, but do not belong in a database table. Never store a value that is dependent on other fields - it's a basic rule of normalization. So, how do you get the calculated field if you do not store it in a table? Use a query Create a query based on your table. Type your expression into the Field row of the query design grid: Amount: [Quantity] * [UnitPrice] This creates a field named Amount. Any form or report based on this query treats the calculated field like any other, so you can easily sum the results. It is simple, efficient, and fool-proof.
YOU WANT TO STORE A CALCULATED RESULT ANYWAY? There are circumstances where storing a calculated result makes sense. Say you charge a construction fee that is normally an additional 10%, but to win some quotes you may want to waive the fee. The calculated field will not work. In this case it makes perfect sense to have a record where the fee is 0 instead of 10%, so you must store this as a field in the table. To achieve this, use the After Update event of the controls on your form to automatically calculate the fee. Set the After Update property of the Quantity text box to [Event Procedure]. Click the Build button (...) beside this. Access opens the Code window. Enter this line between the Private Sub... and End Sub lines: Private Sub Quantity_AfterUpdate() Me.Fee = Round(Me.Quantity * Me.UnitPrice * 0.1, 2) End Sub
Set the After Update property of the UnitPrice text box to [Event Procedure], and click the Build button. Enter this line. Private Sub UnitPrice_AfterUpdate() Call Quantity_AfterUpdate End Sub
Now whenever the Quantity or UnitPrice changes, Access automatically calculates the new fee, but the user can override the calculation and enter a different fee when necessary.
WHAT ABOUT CALCULATED FIELDS IN ACCESS 2010? Access 2010 allows you to put a calculated field into a table, like this:
Just choose Calculated in the data type, and Expression appears below it. Type the expression. Access will then calculate it each time you enter your record. This may seem simple, but it creates more problems than it solves. You will quickly find that the expressions are limited. You will also find it makes your database useless for anyone using older versions of Access - they will get a message like this:
RELATIONSHIPS BETWEEN TABLES Database beginners sometimes struggle with what tables are needed, and how to relate one table to another. It's probably easiest to follow with an example. As a school teacher, Margaret needs to track each student's name and home details, along with the subjects they have taken, and the grades achieved. To do all this in a single table, she could try making fields for: Name Address Home Phone Subject Grade
But this structure requires her to enter the student's name and address again for every new subject! Apart from the time required for entry, can you imagine what happens when a student changes address and Margaret has to locate and update all the previous entries? She tries a different structure with only one record for each student. This requires many additional fields - something like: Name Address Home Phone Name of Subject 1 Grade for Subject 1 Name of Subject 2 Grade for Subject 2 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. Surname - split Surname and First Name to make searches easier FirstName Address - split Street Address, Suburb, and Postcode for the same reason Suburb Postcode Phone
The Subjects table will have fields: SubjectID - a unique code for each subject. (Use the school's subject code) Subject - full title of the subject Notes - comments or a brief description of what this subject covers.
The Grades table will then have just three fields: StudentID - a code that ties this entry to a student in the Students table SubjectID - a code that ties this entry to a subject in the Subjects table Grade - the mark this student achieved in this subject
After creating the three tables, Margaret needs to create a link between them. Now she enters all the students in the Students table, with the unique StudentID for each. Next she enters all the subjects she teaches into the Subjects table, each with a SubjectID. Then at the end of term when the marks are ready, she can enter them in the Grades table using the appropriate StudentID from the Students table and SubjectID from the Subjects table. To help enter marks, she creates a form, using the "Form/Subform" wizard: "Subjects" is the source for the main form, and "Grades" is the source for the subform. Now with the appropriate subject in the main form, and adds each StudentID and Grade in the subform. The grades were entered by subject, but Margaret needs to view them by student. She creates another form/subform, with the main form reading its data from the Students table, and the subform from the Grades table. Since she used StudentID when entering grades in her previous form, Access links this code to the one in the new main form, and automatically displays all the subjects and grades for the student in the main form.
VALIDATION RULES Validation rules prevent bad data being saved in your table. You can create a rule for a field (lower pane of table design), or for the table (in the Properties box in table design.) Use the table's rule to compare fields.
VALIDATION RULES FOR FIELDS When you select a field in table design, you see its Validation Rule property in the lower pane. This rule is applied when you enter data into the field. You cannot tab to the next field until you enter something that satisfies the rule, or undo your entry. To do this ... Validation Rule for Fields Explanation Accept letters (a - z) only Is Null OR Not Like "*[!a-z]*" Any character outside the range A to Z is rejected. (Case insensitive.) Accept digits (0 - 9) only Is Null OR Not Like "*[!0-9]*" Any character outside the range 0 to 9 is rejected. (Decimal point and negative sign rejected.) Letters and spaces only Is Null Or Not Like "*[!a-z OR "" ""]*" Punctuation and digits rejected. Digits and letters only Is Null OR Not Like "*[!((a-z) or (0- 9))]*" Accepts A to Z and 0 to 9, but no punctuation or other characters Exactly 8 characters Is Null OR Like "????????" The question mark stands for one character. Exactly 4 digits Is Null OR Between 1000 And 9999
Is Null OR Like "####" For Number fields.
For Text fields. Positive numbers only Is Null OR >= 0 Remove the "=" if zero is not allowed either. No more than 100% Is Null OR Between -1 And 1 100% is 1. Use 0 instead of -1 if negative percentages are not allowed. Not a future date Is Null OR <= Date() Email address Is Null OR ((Like "*?@?*.?*") AND (Not Like "*[ ,;]*")) Requires at least one character, @, at least one character, dot, at least one character. Space, comma, and semicolon are not permitted. You must fill in Field1 Not Null Same as setting the field's Required property, but lets you create a custom message (in the Validation Text property.)
Limit to specific choices Is Null OR "M" Or "F" It is better to use a lookup table for the list, but this may be useful for simple choices such as Male/Female. Is Null OR IN (1, 2, 4, 8) The IN operator may be simpler than several ORs Yes/No/Null field Is Null OR 0 or -1 The Yes/No field in Access does not support Null as other databases do. To simulate a real Yes/No/Null data type, use a Number field (size Integer) with this rule. (Access uses 0 for False, and -1 for True.)
VALIDATION RULES FOR TABLES In table design, open the Properties box and you see another Validation Rule. This is the rule for the table. The rule is applied after all fields have been entered, just before the record is saved. Use this rule to compare values across different fields, or to delay validation until the last moment before the record is saved.
To do this ... Validation Rule for Table Explanation A booking cannot end before it starts ([StartDate] Is Null) OR ([EndDate] Is Null) OR ([StartDate] <= [EndDate]) The rule is satisfied if either field is left blank; otherwise StartDate must be before (or the same as)EndDate. If you fill in Field1, Field2 is required also ([Field1] Is Null) OR ([Field2] Is Not Null) The rule is satisfied if Field1 is blank; otherwise it is satisfied only if Field2 is filled in. You must enter Field1 or Field2, but not both ([Field1] Is Null) XOR ([Field2] Is Null) XOR is the exclusive OR.
WHEN TO USE VALIDATION RULES A database is only as good as the data it contains, so you want to do everything you can to limit bad data. FIELD'S VALIDATION RULE Take a BirthDate field, for example. Should you create a rule to ensure the user doesn't enter a future date? But did you consider that the computer's date might be wrong? Would it be better to give a warning rather than block the entry? The answer to that question is subjective. The question merely illustrates the need to think outside the box whenever you will block data, not merely to block things just because you cannot imagine a valid scenario for that data. Validation Rules are absolute. You cannot bypass them, so you cannot use them for warnings. To give a warning instead, use an event of your form, such as Form_BeforeUpdate.
ALTERNATIVES TO VALIDATION RULES Use these alternatives instead of or in combination with validation rules: Required: Setting a field's Required property to Yes forces the user to enter something Allow Zero Length: Setting this property to No for text, memo, and hyperlink fields prevents a zero-length string being entered Indexed: To prevent duplicates in a field, set this property to Yes (No Duplicates). Using the Indexes box in table design, you can create a multi-field unique index to the values are unique across a combination of fields Lookups: Rather than creating a validation rule consisting of a list of valid values, consider creating a related table. This is much more flexible and easier to maintain Input Mask: Users must enter the entire pattern (without them you can enter some dates with just 3 keystrokes, e.g. 2/5), and they cannot easily insert a character if they missed one
DON'T USE YES/NO FIELDS TO STORE PREFERENCES A common mistake is to create heaps of Yes/No fields in a table to store people's preferences. This chapter explains how and why you should use a relational design instead. A sports teacher might set up a matrix to record students' interest in various sports like this: Student Basketball Football Baseball Tennis Josh Mark Mary-Anne Olivier Trevor
If the teacher knows nothing about databases, he will create a table with a Text field (for the student name) and a bunch of Yes/No fields so he can tick the sports the student enrols in. Paper forms are laid out like that, so lots of people make the mistake of building database tables like that too.
THINKING RELATIONALLY A major problem with these repeating Yes/No fields is that you must redesign your database every time you add a new choice. To add Netball, the teacher must create another Yes/No field in the table. Then he must modify the queries, forms, reports, and any code or macros that handle these fields. A relational design would avoid this maintenance nightmare. Thinking relationally, we have two things to consider: students, and sports. One student can be in many sports. One sport can have many students. Therefore we have a many-to-many relation between students and sports.
A many-to-many is resolved by using three tables: Student table (one record per student), with fields: StudentID AutoNumber Surname Text FirstName Text
Sport table (one record per sport), with fields: SportID AutoNumber Sport Text
StudentSport table (one record per preference) with fields: StudentSportID AutoNumber StudentID Number Relates to Student.StudentID SportID Number Relates to Sport.SportID
The third table holds the preferences. If Josh is interested in two sports, he has two records in the StudentSport table. This relational structure copes with any number of sports, without needing to redesign the tables. Just add a new record to the Sport table, and the database works without changing all queries, forms, reports, macros, and code. You can also create much more powerful queries: there is only one field to examine to find the sports that match a student (i.e. the SportID field in the StudentSport table.)
CREATING THE USER INTERFACES Create a form bound to the Student table. It has a subform bound to the StudentSport table. This Subform has a combo for selecting the sport, and you add as many rows as you need for that student's sports.
When you add a new sport to the Sport table, it turns up in the combo box automatically. You can therefore choose it without needing any changes.
USING A COMBO BOX TO FIND RECORDS It is possible to use an unbound combo box in the header of a form as a means of record navigation. The idea is to select an entry from the drop-down list, and have Access take you to that record. Assume you have a table called "tblCustomers" with the following structure: CustomerID AutoNumber (indexed as Primary Key). Company Text ContactPerson Text
A form displays data from this table in Single Form view. Add a combo box to the form's header, with the following properties: Name cboMoveTo Control Source [leave this blank] Row Source Type Table/Query Row Source tblCustomers Column Count 3 Column Widths 0.6 in; 1.2 in; 1.2 in Bound Column 1 List Width 3.2 in Limit to List Yes
Now attach this code to the AfterUpdate property of the Combo Box:
Sub CboMoveTo_AfterUpdate () Dim rs As DAO.Recordset
If Not IsNull(Me.cboMoveTo) Then 'Save before move. If Me.Dirty Then Me.Dirty = False End If 'Search in the clone set. Set rs = Me.RecordsetClone rs.FindFirst "[CustomerID] = " & Me.cboMoveTo If rs.NoMatch Then MsgBox "Not found: filtered?" Else 'Display the found record in the form. Me.Bookmark = rs.Bookmark End If Set rs = Nothing End If End Sub
FILTER A FORM ON A FIELD IN A SUBFORM The Filter property of forms (introduced in Access 95) makes it easy to filter a form based on a control in the form. However, the simple filter cannot be used if the field you wish to filter on is not in the form. You can achieve the same result by changing the RecordSource of the main form to an SQL statement with an INNER JOIN to the table containing the field you wish to filter on. If that sounds a mouthful, it is quite simple to do. Before trying to filter on a field in the subform, review how filters are normally used within a form.
SIMPLE FILTER EXAMPLE Take a Products form with a ProductCategory field. With an unbound combo in the form's header, you could provide a simple interface to filter products from one category. The combo would have these properties: Name cboShowCat ' Leave blank ControlSource tblProductCategory 'Your look up table. RowSource AfterUpdate [Event Procedure]
Now when the user selects any category in this combo, its AfterUpdate event procedure filters the form like this: Private Sub cboShowCat_AfterUpdate() If IsNull(Me.cboShowCat) Then Me.FilterOn = False Else Me.Filter = "ProductCatID = """ & Me.cboShowCat & """" Me.FilterOn = True End If End Sub
FILTERING ON A FIELD IN THE SUBFORM You cannot use this simple approach if the field you wish to filter on is in the subform. Some products, for example might have several suppliers. You need a subform for the various suppliers of the product in the main form. The database structure for this example involves three tables: tblProduct, with ProductID as primary key. tblSupplier, with SupplierID as primary key. tblProductSupplier, a link table with ProductID and SupplierID as foreign keys. The main form draws its records from tblProduct, and the subform from tblProductSupplier. When a supplier sends a price update list, how do you filter your main form to only products from this supplier to facilitate changing all those prices? Remember, SupplierID exists only in the subform. One solution is to change the RecordSource of your main form, using an INNER JOIN to get the equivalent of a filter. It is straightforward to create, and the user interface can be identical to the example above. Here are the 2 simple steps to filter the main form to a selected supplier: 1. Add a combo to the header of the main form with these properties:
Name cboShowSup ControlSource 'Leave blank RowSource tblSupplier AfterUpdate [Event Procedure]
2. Click the build button (...) beside the AfterUpdate property. Paste this code between the Sub and End Sub lines:
Dim strSQL As String If IsNull(Me.cboShowSup) Then ' If the combo is Null, use the whole table as the RecordSource. Me.RecordSource = "tblProduct" Else strSQL = "SELECT DISTINCTROW tblProduct.* FROM tblProduct " & _ "INNER JOIN tblProductSupplier ON " & _ "tblProduct.ProductID = tblProductSupplier.ProductID " & _ "WHERE tblProductSupplier.SupplierID = " & Me.cboShowSup & ";" Me.RecordSource = strSQL End If
Although the SELECT statement does not return any fields from tblProductSupplier, the INNER JOIN limits the recordset to products that have an entry for the particular supplier, effectively filtering the products.
COMBINING BOTH TYPES When you change the RecordSource, Access turns the form's FilterOn property off. This means that if you use both the Filter and the change of RecordSource together, your code mustsave the filter state before changing the RecordSource and restore it. Assume you have provided both the combos described above (cboShowCat and cboShowSup) on your main form. A user can now filter only products of a certain category and from a particular supplier. The AfterUpdate event procedure for cboShowSup must save and restore the filter state. Here is the complete code, with error handling.
Private Sub cboShowSup_AfterUpdate() On Error GoTo Err_cboShowSup_AfterUpdate ' Purpose: Change the form's RecordSource to only products from this supplier. Dim sSQL As String Dim bWasFilterOn As Boolean
' Save the FilterOn state. (It's lost during RecordSource change.) bWasFilterOn = Me.FilterOn
' Change the RecordSource. If IsNull(Me.cboShowSup) Then If Me.RecordSource <> "tblProduct" Then Me.RecordSource = "tblProduct" End If Else sSQL = "SELECT DISTINCTROW tblProduct.* FROM tblProduct " & _ "INNER JOIN tblProductSupplier ON " & _ "tblProduct.ProductID = tblProductSupplier.ProductID " & _ "WHERE tblProductSupplier.SupplierID = """ & Me.cboShowSup & """;" Me.RecordSource = sSQL End If
' Apply the filter again, if it was on. If bWasFilterOn And Not Me.FilterOn Then Me.FilterOn = True End If
Exit_cboShowSup_AfterUpdate: Exit Sub
Err_cboShowSup_AfterUpdate: MsgBox Err.Number & ": " & Err.Description, vbInformation, & _ Me.Module.Name & ".cboShowSup_AfterUpdate" Resume Exit_cboShowSup_AfterUpdate End Sub
REFERRING TO CONTROLS ON A SUBFORM Sooner or later, you will need to refer to information in a control on another form - a subform, the parent form, or some other form altogether. Say for example we have a form called "Students" that displays student names and addresses. In this form is a subform called "Grades" that displays the classes passed and credit points earned for the student in the main form. How can the Students form refer to the Credits control in the Grades subform? Access refers to open forms with the Forms prefix: Forms.
Using the dot as a separator, the Surname control on the Students form can be referenced like this: Forms.Students.Surname
If there are spaces in the names of your objects, you will need to use square brackets around the names like this: Forms.[Students Form].[First Name]
Now, the area on a form that contains a subform is actually a control too, and needs to be identified and named in your code. This control has a .form property which refers to the form that it holds. This .form property must be included if you wish to refer to controls in the subform. Forms.Students.Grades.Form.Credits
where Students is the name of the parent form, Grades is the name of the control that holds the subform, and Credits is a control on the subform. Once you get the hang of referring to things this way it is really simple to understand and you will think of more and more uses; To SetValue in code for fields To print a report limited to a certain financial period (the WHERE clause in the OpenReport action) To control buttons on subforms
In code, you can also use Me and Parent to shorten the references. ENTER VALUE AS A PERCENT When you set a field's Format property to "Percent" and enter 7, Access interprets it as 700%. How do you get it to interpret it as 7% without having to type the percent sign for every entry? Use the AfterUpdate event of the control to divide by 100. But then if the user did type "7%", your code changes it to 0.07%! We need to divide by 100 only if the user did not type the percent sign. To do that, examine the Text property of the control. Unlike the control's Value, the Text property is the text as you see it. USING THE CODE You will need to create a new Module in your database called PercentConvert. Choose the Modules tab of the Database window. Click New to open a module. Copy the code below, and paste into your module. Save the module with the name PercentConvert
Public Function MakePercent(txt As TextBox) On Error GoTo Err_Handler 'Purpose: Divide the value by 100 if no percent sign found. 'Usage: Set the After Update property of a text box named Text23 to: ' =MakePercent([Text23])
If Not IsNull(txt) Then If InStr(txt.Text, "%") = 0 Then txt = txt / 100 End If End If
Exit_Handler: Exit Function
Err_Handler: If Err.Number <> 2185 Then 'No Text property unless control has focus. MsgBox "Error " & Err.Number & " - " & Err.Description End If Resume Exit_Handler End Function
Apply your code to a textbox named Text23. Open a form in design view. Add a new textbox to your form. Right-click the text box, and choose Properties. Make sure the name of the textbox is Text23 (if it has a different name, your code will not run correctly. Set the After Update property of the text box to =MakePercent([Text23])
SCROLL RECORDS WITH THE MOUSE WHEEL In earlier versions of Access, scrolling the mouse (using the scrolling wheel) jumped records. This caused a range of problems: Incomplete records were saved People were confused about why their record disappeared
You can disable the mouse wheel in Form view, and scroll records in Datasheet and Continuous view.
CREATE THE CODE On the Create tab of the ribbon, in the Other group, click the arrow below Macro, and choose Module. Access will open a new module. Use the code below for your new Module. To verify Access understands it, choose Compile on the Debug menu.
Public Function DoMouseWheel(frm As Form, lngCount As Long) As Integer On Error GoTo Err_Handler 'Purpose: Make the MouseWheel scroll in Form View in Access 2007 and later. ' This code lets Access 2007 behave like older versions. 'Return: 1 if moved forward a record, -1 if moved back a record, 0 if not moved. 'Author: Allen Browne, February 2007. 'Usage: In the MouseWheel event procedure of the form: ' Call DoMouseWheel(Me, Count) Dim strMsg As String 'Run this only in Access 2007 and later, and only in Form view. If (Val(SysCmd(acSysCmdAccessVer)) >= 12#) And (frm.CurrentView = 1) And (lngCount <> 0&) Then 'Save any edits before moving record. RunCommand acCmdSaveRecord 'Move back a record if Count is negative, otherwise forward. RunCommand IIf(lngCount < 0&, acCmdRecordsGoToPrevious, acCmdRecordsGoToNext) DoMouseWheel = Sgn(lngCount) End If
Exit_Handler: Exit Function
Err_Handler: Select Case Err.Number Case 2046& 'Can't move before first, after last, etc. Beep Case 3314&, 2101&, 2115& 'Can't save the current record. strMsg = "Cannot scroll to another record, as this one can't be saved." MsgBox strMsg, vbInformation, "Cannot scroll" Case Else strMsg = "Error " & Err.Number & ": " & Err.Description MsgBox strMsg, vbInformation, "Cannot scroll" End Select Resume Exit_Handler End Function
Save the module, with a name such as modMouseWheel. Open your form in design view. On the Event tab of the Properties sheet, set the On Mouse Wheel property to [Event Procedure] Click the Build button (...) beside the property. Access opens the code window. Between the Private Sub ... and End Sub lines, enter Call DoMouseWheel(Me, Count) Repeat steps 4 and 5 for your other forms.
HOW IT WORKS The function accepts two arguments: A reference to the form (which will be the active form if the mouse is scrolling it), and The value of Count (a positive number if scrolling forward, or negative if scrolling back.)
Firstly, the code tests the Access version is at least 12 (the internal version number for Access 2007), and the form is in Form view. It does nothing in a previous version or in another view where the mouse scroll still works. It also does nothing if the count is zero, i.e. neither scrolling forward nor back. Before you can move record, Access must save the current record. Explicitly saving is always a good idea, as this clears pending events. If the record cannot be saved (e.g. required field missing), the line generates an error and drops to the error hander which traps the common issues. The highlighted RunCommand moves to the previous record if the Count is negative, or the next record if positive. This generates error 2046 if you try to scroll up above the first record, or down past the last one. Again the error handler traps this error. Finally we set the return value to the sign of the Count argument, so the calling procedure can tell whether we moved record.
AVOID #ERROR IN FORM/REPORT WITH NO RECORDS Calculated expressions show #Error when a form or report has no records. This is known as a Hash Error. This sort-of makes sense for a developer - if the controls don't exist, you cannot sum them. But seeing this type of error can be confising for the user, so the obvious thing to do is eliminate this type of errot.
IN FORMS The problem does not arise in forms that are displaying a new record (in other words the form is ready to accept data for a new record). You will find it does occur if the form's Allow Additions property is Yes, or if the form is bound to a non-updatable query. To avoid the problem, test the RecordCount of the form's Recordset. In older versions of Access, that meant changing:
=Sum([Amount])
to: =IIf([Form].[Recordset].[RecordCount] > 0, Sum([Amount]), 0)
This wont work in newer versions of Access. You will need a new Function to take care of this error.
CODE IT YOUTSELF Copy this function into a standard module, and save the module with a name such as modHashError
Public Function FormHasData(frm As Form) As Boolean 'Purpose: Return True if the form has any records (other than new one). ' Return False for unbound forms, and forms with no records. 'Note: Avoids the bug in Access 2007 where text boxes cannot use: ' [Forms].[Form1].[Recordset].[RecordCount] On Error Resume Next 'To handle unbound forms. FormHasData = (frm.Recordset.RecordCount <> 0&) End Function
Now use this expression in the Control Source of the text box: =IIf(FormHasData([Form]), Sum([Amount]), 0)
IN REPORTS Use the HasData property specifically for this purpose. So, instead of: =Sum([Amount])
use: =IIf([Report].[HasData], Sum([Amount]), 0)
If you have many calculated controls, you need to do this on each one. But note, if Access discovers one calculated control that it cannot resolve, it gives up on calculating the others. Therefore one bad expression can cause other calculated controls to display #Error, even if those controls are bound to valid expressions.
LIMITING A REPORT TO A DATE RANGE There are two methods to limit the records in a report to a user-specified range of dates.
METHOD 1: PARAMETER QUERY The simplest approach is to base the report on a parameter query. This approach works for all kinds of queries, but has these disadvantages: Inflexible: both dates must be entered Inferior interface: two separate dialog boxes pop up No way to supply defaults No way to validate the dates
To create the parameter query you need to create a new query to use as the RecordSource of your report. In query design view, in the Criteria row under your date field, enter: >= [StartDate] < [EndDate] + 1
Choose Parameters from the Query menu, and declare two parameters of type Date/Time: StartDate Date/Time EndDate Date/Time
To display the limiting dates on the report, open your report in Design View, and add two text boxes to the Report Header section. Set their ControlSource property to =StartDateand =EndDate respectively.
METHOD 2: FORM FOR ENTERING THE DATES The alternative is to use a small unbound form where the user can enter the limiting dates. This approach may not work if the query aggregates data, but has the following advantages: Flexible: user does not have to limit report to from and to dates. Better interface: allows defaults and other mechanisms for choosing dates. Validation: can verify the date entries.
Here are the steps. This example assumes a report named rptSales, limited by values in the SaleDate field. Create a new form that is not bound to any query or table. Save with the name frmWhatDates. Add two text boxes, and name them txtStartDate and txtEndDate. Set their Format property to Short Date, so only date entries will be accepted. Add a command button, and set its Name property to cmdPreview. Set the button's On Click property to [Event Procedure] and click the Build button (...) beside this. Access opens the code window. Between the "Private Sub..." and "End Sub" lines paste in the code below.
Private Sub cmdPreview_Click() 'On Error GoTo Err_Handler 'Remove the single quote from start of this line once you have it working. 'Purpose: Filter a report to a date range. 'Documentation: http://allenbrowne.com/casu-08.html 'Note: Filter uses "less than the next day" in case the field has a time component. Dim strReport As String Dim strDateField As String Dim strWhere As String Dim lngView As Long Const strcJetDate = "\#mm\/dd\/yyyy\#" 'Do NOT change it to match your local settings.
'DO set the values in the next 3 lines. strReport = "rptSales" 'Put your report name in these quotes. strDateField = "[SaleDate]" 'Put your field name in the square brackets in these quotes. lngView = acViewPreview 'Use acViewNormal to print instead of preview.
'Build the filter string. If IsDate(Me.txtStartDate) Then strWhere = "(" & strDateField & " >= " & Format(Me.txtStartDate, strcJetDate) & ")" End If If IsDate(Me.txtEndDate) Then If strWhere <> vbNullString Then strWhere = strWhere & " AND " End If strWhere = strWhere & "(" & strDateField & " < " & Format(Me.txtEndDate + 1, strcJetDate) & ")" End If
'Close the report if already open: otherwise it won't filter properly. If CurrentProject.AllReports(strReport).IsLoaded Then DoCmd.Close acReport, strReport End If
'Open the report. 'Debug.Print strWhere 'Remove the single quote from the start of this line for debugging purposes. DoCmd.OpenReport strReport, lngView, , strWhere
Exit_Handler: Exit Sub
Err_Handler: If Err.Number <> 2501 Then MsgBox "Error " & Err.Number & ": " & Err.Description, vbExclamation, "Cannot open report" End If Resume Exit_Handler End Sub
Open the report in Design View, and add two text boxes to the report header for displaying the date range. Set the ControlSource for these text boxes to: =Forms.frmWhatDates.txtStartDate =Forms.frmWhatDates.txtEndDate
Now when you click the Ok button, the filtering works like this: both start and end dates found: filtered between those dates; only a start date found: records from that date onwards; only an end date found: records up to that date only; neither start nor end date found: all records included.
You will end up using this form for all sorts of reports. You may add an option group or list box that selects which report you want printed, and a check box that determines whether the report should be opened in preview mode.
PRINT THE RECORD IN THE FORM How do you print just the one record you are viewing in the form? Create a report, to get the layout right for printing. Use the primary key value that uniquely identifies the record in the form, and open the report with just that one record. The steps Open your form in design view. Click the command button in the toolbox (Access 1 - 2003) or on the Controls group of the Design ribbon (Access 2007 and 2010), and click on your form. If the wizard starts, cancel it. It will not give you the flexibility you need. Right-click the new command button, and choose Properties. Access opens the Properties box. On the Other tab, set the Name to something like: cmdPrint On the Format tab, set the Caption to the text you wish to see on the button, or the Picture if you would prefer a printer or preview icon. On the Event tab, set the On Click property to: [Event Procedure] Click the Build button (...) beside this. Access opens the code window. Paste the code below into the procedure. Replace ID with the name of your primary key field, and MyReport with the name of your report. The code
Private Sub cmdPrint_Click() Dim strWhere As String
If Me.Dirty Then 'Save any edits. Me.Dirty = False End If
If Me.NewRecord Then 'Check there is a record to print MsgBox "Select a record to print" Else strWhere = "[ID] = " & Me.[ID] DoCmd.OpenReport "MyReport", acViewPreview, , strWhere End If End Sub
BRING THE TOTAL FROM A SUBREPORT BACK ONTO THE MAIN REPORT Your subreport has a total at the end - a text box in the Report Footer section, with a Control Source like this: =Sum([Amount]) Now, how do you pass that total back to the the main report? Stage 1 If the subreport is called Sub1, and the text box is txtTotal, put the text box on your main report, and start with this Control Source: =[Sub1].[Report].[txtTotal] Stage 2 Check that it works. It should do if there are records in the subreport. If not, you get #Error. To avoid that, test the HasData property, like this: =IIf([Sub1].[Report].[HasData], [Sub1].[Report].[txtTotal], 0) Stage 3 The subreport total could be Null, so you might like to use Nz() to convert that case to zero also: =IIf([Sub1].[Report].[HasData], Nz([Sub1].[Report].[txtTotal], 0), 0)
Troubleshooting If you are stuck at some point, these further suggestions might help. Total does not work in the subreport If the basic =Sum([Amount]) does not work in the subreport: Make sure the total text box is in the Report Footer section, not the Page Footer section. Make sure the Name of this text box is not the same as the name of a field (e.g. it cannot be called Amount.) The field you are trying to sum must be a field in the report's source table/query. If Amount is a calculated text box such as: =[Quantity]*[PriceEach] then repeat the whole expression in the total box, e.g.: =Sum([Quantity]*[PriceEach]) Make sure that what you are trying to sum is a Number, not text. See Calculated fields misinterpreted. Stage 1 does not work If the basic expression at Stage 1 above does not work: Open the main report in design view. Right-click the edge of the subform control, and choose Properties. Check the Name of the subreport control (on the Other tab of the Properties box.) The Name of the subreport control can be different than the name of the report it contains (its Source Object.) Uncheck the Name AutoCorrect boxes under: Tools | Options | General For details of why, see Failures caused by Name Auto-Correct Stage 2 does not work If Stage 2 does not work but Stage 1 does, you must provide 3 parts for IIf(): an expression that can be True or False (the HasData property in our case), an expression to use when the first part is True (the value from the subreport, just like Stage 1), an expression to use when the first part is False (a zero.)
NUMBERING ENTRIES IN A REPORT OR FORM Report There is a very simple way to number records sequentially on a report. It always works regardless how the report is sorted or filtered. With your report open in Design View: From the Toolbox (Access 1 - 2003) or the Controls group of the Design ribbon (Access 2007 and later), add a text box for displaying the number. Select the text box, and in the Properties Window, set these properties: Control Source =1 Running Sum Over Group That's it! This text box will automatically increment with each record. Form Casual users sometimes want to number records in a form as well, e.g. to save the number of a record so as to return there later. Don't do it! Although Access does show "Record xx ofyy" in the lower left ofthe form, this number can change for any number of reasons, such as: The user clicks the "A-Z" button to change the sort order; The user applies a filter; A new record is inserted; An old record is deleted. In relational database theory, the records in a table cannot have any physical order, so record numbers represent faulty thinking. In place of record numbers, Access uses the Primary Key of the table, or the Bookmark of a recordset. If you are accustomed from another database and find it difficult to conceive of life without record numbers, check out What, no record numbers? You still want to refer to the number of a record in a form as currently filtered and sorted? There are ways to do so. In Access 97 or later, use the form's CurrentRecord property, by adding a text box with this expression in the ControlSource property: =[Form].[CurrentRecord] In Access 2, open your form in Design View in design view and follow these steps: From the Toolbox, add a text box for displaying the number. Select the text box, and in the Properties Window, set its Name to txtPosition. Be sure to leave the Control Source property blank. Select the form, and in the Properties Window set the On Current property to [Event Procedure] . Click the "..." button beside this. Access opens the Code window. Between the lines Sub Form_Current() and End Sub, paste these lines:
On Error GoTo Err_Form_Current Dim rst As Recordset
Err_Form_Current: If Err = 3021 Then 'No current record Me.txtPosition = rst.RecordCount + 1 Else MsgBox Error$, 16, "Error in Form_Current()" End If Resume Exit_Form_Current
The text box will now show a number matching the one between the NavigationButtons on your form. Query For details of how to rank records in a query, see Ranking in a Query
Hide duplicates selectively This article explains how to use the IsVisible property in conjunction with HideDuplicates to selectively hide repeating values on a report. Relational databases are full of one-to-many relations. In Northwind, one Order can have many Order Details. So, in queries and reports, fields from the "One" side of the relation repeat on every row like this:
The HideDuplicates property (on the Format tab of the Properties sheet) helps. Setting HideDuplicates to Yes for OrderID, OrderDate, and CompanyName, gives a more readable report, but is not quite right:
The Date and Company for Order 10617 disappeared, since they were the same the previous order. Similarly, the company name is hidden in order 10619. How can we suppress the date and company only when repeating the same order, but show them for a new order even if they are the same as the previous row? When Access hides duplicates, it sets a special property named IsVisible. By testing the IsVisible property of the OrderID, we can hide the OrderDate and CompanyName only when the OrderID changes. Set the properties of the OrderID text box like this: Control Source . . . =IIf(OrderID.IsVisible,[OrderDate],Null) Hide Duplicates . . . No Name . . . . . . . . . txtOrderDate The Control Source tests the IsVisible property of the OrderID. If it is visible, then the control shows the OrderDate. If it is not visible, it shows Null. Leave the HideDuplicates property turned off. We must change the name as well, because Access gets confused if a control has the same name as a field, but is bound to something else. Similarly, set the ControlSource of the CompanyName text box to: =IIf(OrderID.IsVisible,[CompanyName],Null) and change its name to (say) txtCompanyName. Now the report looks like this:
Note that the IsVisible property is not the same as the Visible property in the Properties box. IsVisible is not available at design time. Access sets it for you when the report runs, for exactly the purpose explained in this article. If you are trying to create the sample report above in the Northwind sample database, here is the query it is based on: SELECT Orders.OrderID, Orders.OrderDate, Customers.CompanyName, [Order Details].ProductID, Products.ProductName, [Order Details].Quantity FROM Products INNER JOIN ((Customers INNER JOIN Orders ON Customers.CustomerID=Orders.CustomerID) INNER JOIN [Order Details] ON Orders.OrderID=[Order Details].OrderID) ON Products.ProductID=[Order Details].ProductID WHERE Orders.OrderID > 10613 ORDER BY Orders.OrderID; In summary, use HideDuplicates where you do want duplicates hidden, but for other controls that should hide at the same time, test the IsVisible property in their ControlSource.
GETTING A VALUE FROM A TABLE: DLOOKUP() Sooner or later, you will need to retrieve a value stored in a table. If you regularly make write invoices to companies, you will have a Company table that contains all the company's details including a CompanyID field, and a Contract table that stores just the CompanyID to look up those details. Sometimes you can base your form or report on a query that contains all the additional tables. Other times, DLookup() will be a life-saver. DLookup() expects you to give it three things inside the brackets. Think of them as: Look up the _____ field, from the _____ table, where the record is _____ Each of these must go in quotes, separated by commas. You must also use square brackets around the table or field names if the names contain odd characters (spaces, #, etc) or start with a number. This is probably easiest to follow with some examples: you have a CompanyID such as 874, and want to print the company name on a report; you have Category such as "C", and need to show what this category means. you have StudentID such as "JoneFr", and need the student?s full name on a form. Example 1: Look up the CompanyName field from table Company, where CompanyID = 874. This translates to: =DLookup("CompanyName", "Company", "CompanyID = 874") You don't want Company 874 printed for every record! Use an ampersand (&) to concatenate the current value in the CompanyID field of your report to the "Company = " criteria: =DLookup("CompanyName", "Company", "CompanyID = " & [CompanyID]) If the CompanyID is null (as it might be at a new record), the 3rd agumenent will be incomplete, so the entire expression yields #Error. To avoid that use Nz() to supply a value for when the field is null: =DLookup("CompanyName", "Company", "CompanyID = " & Nz([CompanyID],0)) Example 2: The example above is correct if CompanyID is a number. But if the field is text, Access expects quote marks around it. In our second example, we look up the CategoryName field in table Cat, where Category = 'C'. This means the DLookup becomes: =DLookup("CategoryName", "Cat", "Category = 'C'") Single quotes within the double quotes is one way to do quotes within quotes. But again, we don't want Categoy 'C' for all records: we need the current value from our Category field patched into the quote. To do this, we close the quotation after the first single quote, add the contents of Category, and then add the trailing single quote. This becomes: =DLookup("CategoryName", "Cat", "Category = '" & [Category] & "'") Example 3: In our third example, we need the full name from a Student table. But the student table has the name split into FirstName and Surname fields, so we need to refer to them both and add a space between. To show this information on your form, add a textbox with ControlSource: =DLookup("[FirstName] & ' ' & [Surname]", "Student", "StudentID = '" & [StudentID] & "'") Quotes inside quotes Now you know how to supply the 3 parts for DLookup(), you are using quotes inside quotes. The single quote character fails if the text contains an apostrophe, so it is better to use the double-quote character. But you must double-up the double-quote character when it is inside quotes.
LOCKING BOUND CONTROLS It is very easy to overwrite data accidentally in Access. Setting a form's AllowEdits property prevents that, but also locks any unbound controls you want to use for filtering or navigation. This solution locks only the bound controls on a form and handles its subforms as well.
First, the code saves any edits in progress, so the user is not stuck with a half-edited form. Next it loops through all controls on the form, setting the Locked property of each one unlessthe control: is an unsuitable type (lines, labels, ...); has no Control Source property (buttons in an option group); is bound to an expression (Control Source starts with "="); is unbound (Control Source is blank); is named in the exception list. (You can specify controls you do not want unlocked.) If it finds a subform, the function calls itself recursively. Nested subforms are therefore handled to any depth. If you do not want your subform locked, name it in the exception list. The form's AllowDeletions property is toggled as well. The code changes the text on the command button to indicate whether clicking again will lock or unlock. To help the user remember they must unlock the form to edit, add a rectangle named rctLock around the edge of your form. The code shows this rectangle when the form is locked, and hides it when unlocked. Using with your forms To use the code: Open a new module. In Access 95 - 2003, click the Modules tab of the Database window, and click New. In Access 2007 and later, click Module (rightmost icon) on the Create ribbon. Access opens a code module. Paste in the code from the end of this article. Save the module with a name such as ajbLockBound. (Optional) Add a red rectangle to your form to indicate it is locked. Name it rctLock. To initialize the form so it comes up locked, set the On Load property of your form to: =LockBoundControls([Form],True) Add a command button to your form. Name it cmdLock. Set its On Click property to [Event Procedure]. Click the Build button (...) beside this. Set up the code like this:
Private Sub cmdLock_Click() Dim bLock As Boolean bLock = IIf(Me.cmdLock.Caption = "&Lock", True, False) Call LockBoundControls(Me, bLock) End Sub
(Optional) Add the names of any controls you do not want unlocked at steps 3 and 4. For example, to avoid unlocking controls EnteredOn and EnteredBy in the screenshot above, you would use: Call LockBoundControls(Me, bLock, "EnteredOn", "EnteredBy") Note that if your form has any disabled controls, changing their Locked property affects the way they look. To avoid this, add them to the exception list. The code Public Function LockBoundControls(frm As Form, bLock As Boolean, ParamArray avarExceptionList()) On Error GoTo Err_Handler 'Purpose: Lock the bound controls and prevent deletes on the form any its subforms. 'Arguments frm = the form to be locked ' bLock = True to lock, False to unlock. ' avarExceptionList: Names of the controls NOT to lock (variant array of strings). 'Usage: Call LockBoundControls(Me. True) Dim ctl As Control 'Each control on the form Dim lngI As Long 'Loop controller. Dim bSkip As Boolean
'Save any edits. If frm.Dirty Then frm.Dirty = False End If 'Block deletions. frm.AllowDeletions = Not bLock
For Each ctl In frm.Controls Select Case ctl.ControlType Case acTextBox, acComboBox, acListBox, acOptionGroup, acCheckBox, acOptionButton, acToggleButton 'Lock/unlock these controls if bound to fields. bSkip = False For lngI = LBound(avarExceptionList) To UBound(avarExceptionList) If avarExceptionList(lngI) = ctl.Name Then bSkip = True Exit For End If Next If Not bSkip Then If HasProperty(ctl, "ControlSource") Then If Len(ctl.ControlSource) > 0 And Not ctl.ControlSource Like "=*" Then If ctl.Locked <> bLock Then ctl.Locked = bLock End If End If End If End If
Case acSubform 'Recursive call to handle all subforms. bSkip = False For lngI = LBound(avarExceptionList) To UBound(avarExceptionList) If avarExceptionList(lngI) = ctl.Name Then bSkip = True Exit For End If Next If Not bSkip Then If Len(Nz(ctl.SourceObject, vbNullString)) > 0 Then ctl.Form.AllowDeletions = Not bLock ctl.Form.AllowAdditions = Not bLock Call LockBoundControls(ctl.Form, bLock) End If End If
Case Else 'Includes acBoundObjectFrame, acCustomControl Debug.Print ctl.Name & " not handled " & Now() End Select Next
'Set the visual indicators on the form. On Error Resume Next frm.cmdLock.Caption = IIf(bLock, "Un&lock", "&Lock") frm!rctLock.Visible = bLock
Exit_Handler: Set ctl = Nothing Exit Function
Err_Handler: MsgBox "Error " & Err.Number & " - " & Err.Description Resume Exit_Handler End Function
Public Function HasProperty(obj As Object, strPropName As String) As Boolean 'Purpose: Return true if the object has the property. Dim varDummy As Variant On Error Resume Next varDummy = obj.Properties(strPropName) HasProperty = (Err.Number = 0) End Function
NULLS: DO I NEED THEM? Why have Nulls? Learning to handle Nulls can be frustrating. Occasionally I hear newbies ask, "How can I prevent them?" Nulls are a very important part of your database, and it is essential that you learn to handle them. A Null is "no entry" in a field. The alternative is to require an entry in every field of every record! You turn up at a hospital too badly hurt to give your birth date, and they won't let you in because the admissions database can't leave the field null? Since some fields must be optional, so you must learn to handle nulls. Nulls are not a problem invented by Microsoft Access. They are a very important part of relational database theory and practice, part of any reasonable database. Ultimately you will come to see the Null as your friend. Think of Null as meaning Unknown.
Null is not the same as zero Open the Immediate Window (press Ctrl+G), and enter: ? Null = 0 VBA responds, Null. In plain English, you asked VBA, Is an Unknown equal to Zero?, and VBA responded with, I don't know. Null is not the same as zero. If an expression contains a Null, the result is often Null. Try: ? 4 + Null VBA responds with Null, i.e. The result is Unknown. The technical name for this domino effect is Null propagation. Nulls are treated differently from zeros when you count or average a field. Picture a table with an Amount field and these values in its 3 records: 4, 5, Null In the Immediate window, enter: ? DCount("Amount", "MyTable") VBA responds with 2. Although there are three records, there are only two known values to report. Similarly, if you ask: ? DAvg("Amount", "MyTable") VBA responds with 4.5, not 3. Nulls are excluded from operations such as sum, count, and average. Hint: To count all records, use Count("*") rather than Count("[SomeField]"). That way Access can respond with the record count rather than wasting time checking if there are nulls to exclude.
Null is not the same as a zero-length string VBA uses quote marks that open and immediately close again to represent a string with nothing in it. If you have no middle name, it could be represented as a zero-length string. That is not the same as saying your middle name is unknown (Null). To demonstrate the difference, enter this into the Immediate window: ? Len(""), Len(Null) VBA responds that the length of the first string is zero, but the length of the unknown is unknown (Null). Text fields in an Access table can contain a zero-length string to distinguish Unknown from Non-existent. However, there is no difference visible to the user, so you are likely to confuse the user (as well as the typical Access developer.) Recent versions of Access default this property to Yes: we recommend you change this property for all Text and Memo fields. Details and code in Problem Properties.
Null is not the same as Nothing or Missing These are terms that sound similar but mean do not mean the same as Null, the unknown value. VBA uses Nothing to refer to an unassigned object, such as a recordset that has been declared but not set. VBA uses Missing to refer to an optional parameter of a procedure. To help you avoid common traps in handling nulls, see: Common Errors with Null
COMMON ERRORS WITH NULL Here are some common mistakes newbies make with Nulls. Error 1: Nulls in Criteria If you enter criteria under a field in a query, it returns only matching records. Nulls are excluded when you enter criteria. For example, say you have a table of company names and addresses. You want two queries: one that gives you the local companies, and the other that gives you all the rest. In the Criteria row under the City field of the first query, you type: "Springfield" and in the second query: Not "Springfield" Wrong! Neither query includes the records where City is Null. Solution Specify Is Null. For the second query above to meet your design goal of "all the rest", the criteria needs to be: Is Null Or Not "Springfield" Note: Data Definition Language (DDL) queries treat nulls differently. For example, the nulls are counted in this kind of query: ALTER TABLE Table1 ADD CONSTRAINT chk1 CHECK (99 < (SELECT Count(*) FROM Table2 WHERE Table2.State <> 'TX'));
Error 2: Nulls in expressions Maths involving a Null usually results in Null. For example, newbies sometimes enter an expression such as this in the ControlSource property of a text box, to display the amount still payable: =[AmountDue] - [AmountPaid] The trouble is that if nothing has been paid, AmountPaid is Null, and so this text box displays nothing at all. Solution Use the Nz() function to specify a value for Null: = Nz([AmountDue], 0) - Nz([AmountPaid], 0)
Error 3: Nulls in Foreign Keys While Access blocks nulls in primary keys, it permits nulls in foreign keys. In most cases, you should explicitly block this possibility to prevent orphaned records. For a typical Invoice table, the line items of the invoice are stored in an InvoiceDetail table, joined to the Invoice table by an InvoiceID. You create a relationship between Invoice.InvoiceID and InvoiceDetail.InvoiceID, with Referential Integrity enforced. It's not enough! Unless you set the Required property of the InvoiceID field to Yes in the InvoiceDetail table, Access permits Nulls. Most often this happens when a user begins adding line items to the subform without first creating the invoice itself in the main form. Since these records don't match any record in the main form, these orphaned records are never displayed again. The user is convinced your program lost them, though they are still there in the table. Solution Always set the Required property of foreign key fields to Yes in table design view, unless you expressly want Nulls in the foreign key.
Error 4: Nulls and non-Variants In Visual Basic, the only data type that can contain Null is the Variant. Whenever you assign the value of a field to a non-variant, you must consider the possibility that the field may be null. Can you see what could go wrong with this code in a form's module? Dim strName as String Dim lngID As Long strName = Me.MiddleName lngID = Me.ClientID When the MiddleName field contains Null, the attempt to assign the Null to a string generates an error. Similarly the assignment of the ClientID value to a numeric variable may cause an error. Even if ClientID is the primary key, the code is not safe: the primary key contains Null at a new record. Solutions (a) Use a Variant data type if you need to work with nulls. (b) Use the Nz() function to specify a value to use for Null. For example: strName = Nz(Me.MiddleName, "") lngID = Nz(Me.ClientID, 0)
Error 5: Comparing something to Null The expression: If [Surname] = Null Then is a nonsense that will never be True. Even if the surname is Null, VBA thinks you asked: Does Unknown equal Unknown? and always responds "How do I know whether your unknowns are equal?" This is Null propagation again: the result is neither True nor False, but Null. Solution Use the IsNull() function: If IsNull([Surname]) Then
Error 6: Forgetting Null is neither True nor False. Do these two constructs do the same job? (a) If [Surname] = "Smith" Then MsgBox "It's a Smith" Else MsgBox "It's not a Smith" End If
(b) If [Surname] <> "Smith" Then MsgBox "It's not a Smith" Else MsgBox "It's a Smith" End If When the Surname is Null, these 2 pieces of code contradict each other. In both cases, the If fails, so the Else executes, resulting in contradictory messages. Solutions (a) Handle all three outcomes of a comparison - True, False, and Null: If [Surname] = "Smith" Then MsgBox "It's a Smith" ElseIf [Surname] <> "Smith" Then MsgBox "It's not a Smith" Else MsgBox "We don't know if it's a Smith" End If (b) In some cases, the Nz() function lets you to handle two cases together. For example, to treat a Null and a zero-length string in the same way: If Len(Nz([Surname],"")) = 0 Then
PROBLEM PROPERTIES Recent versions of Access have introduced new properties or changed the default setting for existing properties. Accepting the new defaults causes failures, diminished integrity, performance loss, and exposes your application to tinkerers. Databases: Name AutoCorrect Any database created with Access 2000 or later, has the Name AutoCorrect properties on. You must remember to turn it off for every new database you create: In Access 2010, click File | Options | Current Database, and scroll down to Name AutoCorrect Options. In Access 2007, click the Office Button | Access Options | Current Database, and scroll down to Name AutoCorrect Options. In Access 2000 - 2003, the Name AutoCorrect boxes are under Tools | Options | General. The problems associated with this property are wide-ranging. For details, see: Failures caused by Name Auto-Correct. You may also wish to turn off Record-level locking: In Access 2010: File | Options | Advanced. In Access 2007: Office Button | Access Options | Advanced. In Access 2000 - 2003: Tools | Options | Advanced. Although record-level locking may be desirable in some heavily networked applications, there is a performance hit. Even more significantly, if you have attached tables from Access 97 or earlier and record-level locking is enabled, some DAO transactions may fail. (The scenario that uncovered this bug involved de-duplicating clients - reassigning related records, and then removing the duplicate.) In Access 2007 and later, you will also want to uncheck the box labelled Enable design changes for tables in Datasheet view (for this database) under File (Office Button) | Access Options | Current Database. In Access 2007 and later you can create a template database that sets these settings for every new database. For details, see Default forms, reports and databases. Fields: Allow Zero Length Table fields created in Access 97 had their Allow Zero Length property set to No by default. In Access 2000 and later, the property defaults to Yes, and you must remember to turn it off every time you add a field to a table. To the end user, there is no visible difference between a zero-length string (ZLS) and a Null, and the distinction should not be forced upon them. The average Access developer has enough trouble validating Nulls without having to handle the ZLS/Null distinction as well in every event procedure of their application. The savvy developer uses engine-level validation wherever possible, and permits a ZLS only in rare and specific circumstances. There is no justification for having this property on by default. There is no justification for the inconsistency with previous versions. Even Access itself gets the distinction between Null and ZLS wrong: DLookup() returns Null when it should yield a ZLS. You must therefore set this property for every field in the database where you do not wish to explicitly permit a ZLS. To save you doing so manually, this code loops through all your tables, and sets the property for each field: Function FixZLS() Dim db As DAO.Database Dim tdf As DAO.TableDef Dim fld As DAO.Field Dim prp As DAO.Property Const conPropName = "AllowZeroLength" Const conPropValue = False
Set db = CurrentDb() For Each tdf In db.TableDefs If (tdf.Attributes And dbSystemObject) = 0 Then If tdf.Name <> "Switchboard Items" Then For Each fld In tdf.Fields If fld.Properties(conPropName) Then Debug.Print tdf.Name & "." & fld.Name fld.Properties(conPropName) = conPropValue End If Next End If End If Next
Set prp = Nothing Set fld = Nothing Set tdf = Nothing Set db = Nothing End Function
How crazy is this? We are now running code to get us back to the functionality we had in previous versions? And you have to keep remembering to set these properties with any structural changes? This is enhanced usability? If you create fields programmatically, be aware that these field properties are set inconsistently. The setting you get for Allow Zero Length, Unicode Compression, and other properties depends on whether you use DAO, ADOX, or DDL to create the field. Prior to Access 2007, numeric fields always defaulted to zero, so you had to manually remove the Default Value whenever you created a Number type field. It was particularly important to do so for foreign key fields. Tables: SubdatasheetName In Access 2000, tables got a new property called SubdatasheetName. If the property is not set, it defaults to "[Auto]". Its datasheet displays a plus sign which the user can click to display related records from some other table that Access thinks may be useful. This automatically assigned property is inherited by forms and subforms displayed in datasheet view. Clearly, this is not a good idea and may have unintended consequences in applications imported from earlier versions. Worse still, there are serious performance issues associated with loading a form that has several subforms where Access is figuring out and collecting data from multiple more related tables. Again, the solution is to turn off subdatasheets by setting the property to "[None]". Again, there is no way to do this by default, so you must remember to do so every time you create a table. This code will loop through your tables and turn the property off: Function TurnOffSubDataSh() Dim db As DAO.Database Dim tdf As DAO.TableDef Dim prp As DAO.Property Const conPropName = "SubdatasheetName" Const conPropValue = "[None]"
Set db = DBEngine(0)(0) For Each tdf In db.TableDefs If (tdf.Attributes And dbSystemObject) = 0 Then If tdf.Connect = vbNullString And Asc(tdf.Name) <> 126 Then 'Not attached, or temp. If Not HasProperty(tdf, conPropName) Then Set prp = tdf.CreateProperty(conPropName, dbText, conPropValue) tdf.Properties.Append prp Else If tdf.Properties(conPropName) <> conPropValue Then tdf.Properties(conPropName) = conPropValue End If End If End If End If Next
Set prp = Nothing Set tdf = Nothing Set db = Nothing End Function
Public Function HasProperty(obj As Object, strPropName As String) As Boolean 'Purpose: Return true if the object has the property. Dim varDummy As Variant
On Error Resume Next varDummy = obj.Properties(strPropName) HasProperty = (Err.Number = 0) End Function Forms: Allow Design Changes The Allow Design Changes property for new forms defaults to True ("All Views"). This is highly undesirable for developers. It is also undesirable for tinkerers, as there is some evidence that altering the event procedures while the form is open (not design view) can contribute to corruption. (In Access 2007 and later, this property seems to be removed from the Property Sheet and ignored by the interface, though it is still present and still defaults to True.) Again, we find ourselves having to work around the new defaults. Rather than setting these properties every time you create a form, consider taking a few moments to create someDefault Forms and Reports. Find Dialog You should also be aware that the Find dialog (default form toolbar, Edit menu, or Ctrl+F) now exposes a Replace tab. This allows users to perform bulk alterations on data without the checks normally performed by Form_BeforeUpdate or follow-ons in Form_AfterUpdate. This seems highly undesirable in a database that provides no triggers at the engine level. A workaround for this behavior is to temporarily set the AllowEdits property of the form to No before you DoCmd.RunCommand acCmdFind.
DEFAULT FORMS, REPORTS AND DATABASES Access provides a way to set up a form and a report, and nominate them as the template for new forms and reports: in Access 2010: File | Access Options | Object Designers, in Access 2007: Office Button | Access Options | Object Designers, in Access 1 2003: Tools | Options | Forms/Reports. That's useful, as it lets you create forms and reports quickly to your own style. However, these forms/reports do not inherit all properties and code. You will get a better result if you copy and paste your template form or report in the database window (Access 1 - 2003) or Nav Pane (Access 2007 and later.) The form created this way inherits all properties and event procedures. It will take you 30-45 minutes to set up these default documents. They will save 5-15 minutes on every form or report you create. A default form Create a new form, in design view. If you normally provide navigation or filtering options in the Form Header section, display it: in Access 2010: right-click the Detail section, and choose Form Header/Footer, in Access 2007: Show/Hide (rightmost icon) on the Layout ribbon, in Access 1-2003: Form Header/Footer on View menu. Drag these sections to the appropriate height. In addition to your visual preferences, consider setting properties such as these: Allow Design Changes Design View Only Disallow runtime changes. (Access 2003 and earlier.) Allow PivotTable View No Disallowing these views prevents tinkerers from trying them from the toolbar or View menu. Allow PivotChart View No Width 6" Adjust for the minimum screen resolution you anticipate. Now comes the important part: set the default properties for each type of control. Select the Textbox icon in the Toolbox (Access 1 - 2003) or on the Controls group of the Design ribbon (Access 2007 and later.) The title of the Properties box reads, "Default Text Box". Set the properties that new text boxes should inherit, such as: Special Effect Flat Whatever your style is. Font Name MS Sans Serif Choose a font that will definitely be on your user's system. Allow AutoCorrect No Generally you want this on for memo fields only. Repeat the process for the default Combo Box as well. Be sure to turn Auto Correct off - it is completely inappropriate for Access to correct items you are selecting from a list. Set properties such as Font Name for the default Label, Command Button, and other controls. Add any event procedures you usually want, such as: Form_BeforeUpdate, to validate the record; Form_Error, to trap data errors; Form_Close, to ensure something (such as a Switchboard) is still open. Save the form. A name that sorts first makes it easy to copy and paste the form to create others. A default Continuous Form Copy and paste the form created above. This form will be the one you copy and paste to create continuous forms. You have already done most of the work, but the additional properties for a continuous form might include: Set the form's Default View property to Continuous Forms. For the default Text Box, set Add Colon to No. This will save removing the colon from each attached label when you cut them from the Detail section and paste them into the Form Header. If your continuous forms are usually subforms, consider adding code to cancel the form's Before Insert event if there is no record in the parent form. Create other "template forms" as you have need. A default report The default report is designed in exactly the same way as the forms above. Create a blank report, and set its properties and the default properties for each control in the Toolbox. Suggestions: Set the default margins to 0.7" all round, as this copes with the Unprintable area of most printers: In Access 2010, click Page Setup on the Page Setup ribbon. In Access 2007, click the Extend arrow at the very bottom right of the Page Layout group on the Page Setup ribbon. In Access 1 - 2003, choose Page Setup from the File menu, and click the Margins tab.
Set the report's Width to 6.85". (Handles Letter and A4 with 1.4" for margins.)
Show the Report Header/Footer (View menu in Access 1 - 2003; in Access 2007, the rightmost icon in the Show/Hide group on the Layout ribbon). In Access 2010, right-click the Detail section, and choose Report Header/Footer. In Access 2007, Show/Hide (rightmost icon) on the Layout ribbon. In Access 1 - 2003, View menu.
Add a text box to the Report Header section to automatically print the report's caption as its title. Its Control Source will be: =[Report].[Caption]
Add a text box to the Page Footer section to show the page count. Use a Control Source of: ="Page " & [Page] & " of " & [Pages]
Set the On No Data property to: =NoData([Report])
The last suggestion avoids displaying "#Error" when the report has no data. Copy the function below, and paste into a general module. Using the generic function means you automatically get this protection with each report, yet it remains lightweight (no module) which helps minimize the possibility of corruption. The code is:
Public Function NoData(rpt As Report) 'Purpose: Called by report's NoData event. 'Usage: =NoData([Report]) Dim strCaption As String 'Caption of report.
strCaption = rpt.Caption If strCaption = vbNullString Then strCaption = rpt.Name End If
DoCmd.CancelEvent MsgBox "There are no records to include in report """ & _ strCaption & """.", vbInformation, "No Data..." End Function
A default database In Access 2007 and later, you can also create a default database, with the properties, objects, and configuration you want whenever you create a new (blank) database. Click the Office Button, and click New. Enter this file name: C:\Program Files\Microsoft Office\Templates\1033\Access\blank and click Create. The name and location of the database are important. If you installed Office to a different folder, locate the Templates on your computer. To set the database properties, click the Office Button and choose Access Options. On the Current Database tab of the dialog, uncheck the Name AutoCorrect options to prevent these bugs. On the Object Designers tab, uncheck Enable design changes for tables in Datasheet view to prevent users modifying your schema. Set other preferences (such as tabbed documents or overlapping windows, and showing the Search box in the Nav Pane.) After setting the options, set the references you want for your new databases. Open the code window (Alt+F11) and choose References on the Tools menu. Import any objects you always want in a new database, such as: the default form and report above, modules containing your commonly used functions, tables where you store configuration data, your splash screen, or other commonly used forms. To import, click the External Data tab on the ribbon, then the Import Access Database icon on the Import group. Now any new database you create will have these objects included, properties set, and references selected. You can create default databases for both the new file format (accdb) and the old format (mdb) by creating both a blank.accdb and a blank.mdb in the Access templates folder.
CALCULATING ELAPSED TIME How do you calculate the difference between two date/time fields, such as the hours worked between clock- on and clock-off? Use DateDiff() to calculate the elapsed time. It returns whole numbers only, so if you want hours and fractions of an hour, you must work in minutes. If you want minutes and seconds, you must get the difference in seconds. Let's assume a date/time field named StartDateTime to record when the employee clocks on, and another named EndDateTime for when the employee clocks off. To calculate the time worked, create a query into this table, and type this into the Field row of the query design grid: Minutes: DateDiff("n", [StartDateTime], [EndDateTime])
Minutes is the alias for the calculated field; you could use any name you like. You must use "n" for DateDiff() to return minutes: "m" returns months. To display this value as hours and minutes on your report, use a text box with this Control Source: =[Minutes] \ 60 & Format([Minutes] Mod 60, "\:00") This formula uses: the integer division operator (\) rather than regular division (/), for whole hours only; the Mod operator to get the left over minutes after dividing by 60; the Format() function to display the minutes as two digits with a literal colon. Do not use the formula directly in the query if you wish to sum the time; the value it generates is just a piece of text.
If you need to calculate a difference in seconds, use "s": Seconds: DateDiff("s", [StartDateTime], [EndDateTime]) You can work in seconds for durations up to 67 years. If you need to calculate the amount of pay due to the employee based on an HourlyRate field, use something like this: PayAmount: Round(CCur(Nz(DateDiff("n", [StartDateTime], [EndDateTime]) * [HourlyRate] / 60, 0)), 2)
A MORE COMPLETE DATEDIFF FUNCTION The following is a function I helped Graham Seach develop. As it states, it lets you calculate a "precise" difference between two date/time values. You specify how you want the difference between two date/times to be calculated by providing which of ymwdhns (for years, months, weeks, days, hours, minutes and seconds) you want calculated. For example: ?Diff2Dates("y", #06/01/1998#, #06/26/2002#) 4 years ?Diff2Dates("ymd", #06/01/1998#, #06/26/2002#) 4 years 25 days ?Diff2Dates("ymd", #06/01/1998#, #06/26/2002#, True) 4 years 0 months 25 days ?Diff2Dates("ymwd", #06/01/1998#, #06/26/2002#, True) 4 years 0 months 3 weeks 4 days ?Diff2Dates("d", #06/01/1998#, #06/26/2002#) 1486 days
?Diff2Dates("ymd",#12/31/1999#,#1/1/2000#) 1 day ?Diff2Dates("ymd",#1/1/2000#,#12/31/1999#) -1 day ?Diff2Dates("ymd",#1/1/2000#,#1/2/2000#) 1 day Special thanks to Mike Preston for pointing out an error in how it presented values when Date1 is before Date2. Updated 2012-08-07 as the results of a request in UtterAccess. Please note that this addition has not been as thoroughly tested as usual. Please let me know if you have any problems with it!
'***************** Code Start ************** Public Function Diff2Dates(Interval As String, Date1 As Variant, Date2 As Variant, _ Optional ShowZero As Boolean = False) As Variant 'Author: ? Copyright 2001 Pacific Database Pty Limited ' Graham R Seach MCP MVP gseach@pacificdb.com.au ' Phone: +61 2 9872 9594 Fax: +61 2 9872 9593 ' This code is freeware. Enjoy... ' (*) Amendments suggested by Douglas J. Steele MVP ' 'Description: This function calculates the number of years, ' months, days, hours, minutes and seconds between ' two dates, as elapsed time. ' 'Inputs: Interval: Intervals to be displayed (a string) ' Date1: The lower date (see below) ' Date2: The higher date (see below) ' ShowZero: Boolean to select showing zero elements ' 'Outputs: On error: Null ' On no error: Variant containing the number of years, ' months, days, hours, minutes & seconds between ' the two dates, depending on the display interval ' selected. ' If Date1 is greater than Date2, the result will ' be a negative value. ' The function compensates for the lack of any intervals ' not listed. For example, if Interval lists "m", but ' not "y", the function adds the value of the year ' component to the month component. ' If ShowZero is True, and an output element is zero, it ' is displayed. However, if ShowZero is False or ' omitted, no zero-value elements are displayed. ' For example, with ShowZero = False, Interval = "ym", ' elements = 0 & 1 respectively, the output string ' will be "1 month" - not "0 years 1 month".
On Error GoTo Err_Diff2Dates
Dim booCalcYears As Boolean Dim booCalcMonths As Boolean Dim booCalcDays As Boolean Dim booCalcHours As Boolean Dim booCalcMinutes As Boolean Dim booCalcSeconds As Boolean Dim booCalcWeeks As Boolean Dim booSwapped As Boolean Dim dtTemp As Date Dim intCounter As Integer Dim lngDiffYears As Long Dim lngDiffMonths As Long Dim lngDiffDays As Long Dim lngDiffHours As Long Dim lngDiffMinutes As Long Dim lngDiffSeconds As Long Dim lngDiffWeeks As Long Dim varTemp As Variant
Const INTERVALS As String = "dmyhnsw"
'Check that Interval contains only valid characters Interval = LCase$(Interval) For intCounter = 1 To Len(Interval) If InStr(1, INTERVALS, Mid$(Interval, intCounter, 1)) = 0 Then Exit Function End If Next intCounter
'Check that valid dates have been entered If IsNull(Date1) Then Exit Function If IsNull(Date2) Then Exit Function If Not (IsDate(Date1)) Then Exit Function If Not (IsDate(Date2)) Then Exit Function
'If necessary, swap the dates, to ensure that 'Date1 is lower than Date2 If Date1 > Date2 Then dtTemp = Date1 Date1 = Date2 Date2 = dtTemp booSwapped = True End If
'Get the cumulative differences If booCalcYears Then lngDiffYears = Abs(DateDiff("yyyy", Date1, Date2)) - _ IIf(Format$(Date1, "mmddhhnnss") <= Format$(Date2, "mmddhhnnss"), 0, 1) Date1 = DateAdd("yyyy", lngDiffYears, Date1) End If
If booCalcMonths Then lngDiffMonths = Abs(DateDiff("m", Date1, Date2)) - _ IIf(Format$(Date1, "ddhhnnss") <= Format$(Date2, "ddhhnnss"), 0, 1) Date1 = DateAdd("m", lngDiffMonths, Date1) End If
If booCalcWeeks Then lngDiffWeeks = Abs(DateDiff("w", Date1, Date2)) - _ IIf(Format$(Date1, "hhnnss") <= Format$(Date2, "hhnnss"), 0, 1) Date1 = DateAdd("ww", lngDiffWeeks, Date1) End If
If booCalcDays Then lngDiffDays = Abs(DateDiff("d", Date1, Date2)) - _ IIf(Format$(Date1, "hhnnss") <= Format$(Date2, "hhnnss"), 0, 1) Date1 = DateAdd("d", lngDiffDays, Date1) End If
If booCalcHours Then lngDiffHours = Abs(DateDiff("h", Date1, Date2)) - _ IIf(Format$(Date1, "nnss") <= Format$(Date2, "nnss"), 0, 1) Date1 = DateAdd("h", lngDiffHours, Date1) End If
If booCalcMinutes Then lngDiffMinutes = Abs(DateDiff("n", Date1, Date2)) - _ IIf(Format$(Date1, "ss") <= Format$(Date2, "ss"), 0, 1) Date1 = DateAdd("n", lngDiffMinutes, Date1) End If
If booCalcSeconds Then lngDiffSeconds = Abs(DateDiff("s", Date1, Date2)) Date1 = DateAdd("s", lngDiffSeconds, Date1) End If
If booCalcYears And (lngDiffYears > 0 Or ShowZero) Then varTemp = lngDiffYears & IIf(lngDiffYears <> 1, " years", " year") End If
If booCalcMonths And (lngDiffMonths > 0 Or ShowZero) Then If booCalcMonths Then varTemp = varTemp & IIf(IsNull(varTemp), Null, " ") & _ lngDiffMonths & IIf(lngDiffMonths <> 1, " months", " month") End If End If
If booCalcWeeks And (lngDiffWeeks > 0 Or ShowZero) Then If booCalcWeeks Then varTemp = varTemp & IIf(IsNull(varTemp), Null, " ") & _ lngDiffWeeks & IIf(lngDiffWeeks <> 1, " weeks", " week") End If End If
If booCalcDays And (lngDiffDays > 0 Or ShowZero) Then If booCalcDays Then varTemp = varTemp & IIf(IsNull(varTemp), Null, " ") & _ lngDiffDays & IIf(lngDiffDays <> 1, " days", " day") End If End If
If booCalcHours And (lngDiffHours > 0 Or ShowZero) Then If booCalcHours Then varTemp = varTemp & IIf(IsNull(varTemp), Null, " ") & _ lngDiffHours & IIf(lngDiffHours <> 1, " hours", " hour") End If End If
If booCalcMinutes And (lngDiffMinutes > 0 Or ShowZero) Then If booCalcMinutes Then varTemp = varTemp & IIf(IsNull(varTemp), Null, " ") & _ lngDiffMinutes & IIf(lngDiffMinutes <> 1, " minutes", " minute") End If End If
If booCalcSeconds And (lngDiffSeconds > 0 Or ShowZero) Then If booCalcSeconds Then varTemp = varTemp & IIf(IsNull(varTemp), Null, " ") & _ lngDiffSeconds & IIf(lngDiffSeconds <> 1, " seconds", " second") End If End If
If booSwapped Then varTemp = "-" & varTemp End If
Diff2Dates = Trim$(varTemp)
End_Diff2Dates: Exit Function
Err_Diff2Dates: Resume End_Diff2Dates
End Function '************** Code End *****************
CONSTRUCTING MODERN TIME ELAPSED STRINGS IN ACCESS Office 2007 This content is outdated and is no longer being maintained. It is provided as a courtesy for individuals who are still using these technologies. This page may contain URLs that were valid when originally published, but now link to sites or pages that no longer exist. Summary: Learn how to use Microsoft Office Access 2007 to display the time elapsed between the current date and another date. (5 printed pages) Kerry Westphal, Microsoft Corporation March 2009 Applies to: 2007 Microsoft Office system, Microsoft Office Access 2007 Overview Many Web 2.0 applications are designed to make it easy to vizualize complex data. I found myself recently challenged with this task while working on a project where I wanted to display on a report to show the time elapsed between the current date and another date. Some example scenarios could include how much time has elapsed since a user profile was updated, the time that remains until taxes are due, or how long a library book was checked out. I did not merely want to show the hours or even days elapsed, but something more in sync with the way I want the information given to mespecifically, that when dates are closer to the current date and time that they are represented exactly, and dates and times that are farther away are shown generally. I wrote the ElapsedTime user- defined function to perform this task. The function can be used in a query to obtain a string that represents the time elapsed. The string returned is either specific or general depending on the length of time elapsed. For example, if the date is close to the current date, it appears as "In 12 hours, 27 minutes". If the date was long ago, it appears as, "A year ago". The following screen shot shows the results of the ElapsedTime function when it is used to track items in a calendar. Figure 1. Report showing modern elapsed time string
How It Works The ElapsedTime function does the work. Call ElapsedTime from a form, report, or query to get a string that shows the time elapsed between the date that you pass the function and the current date. Pass ElapsedTime a date/time value as its only argument and the rest is completed for you.
Public Function ElapsedTime(dateTimeStart As Date) As String '************************************************************* ' Function ElapsedTime(dateTimeStart As Date) As String ' Returns the time elapsed from today in a display string like, ' "In 12 hours, 41 minutes" '************************************************************* On Error GoTo ElapsedTime_Error Dim result As String Dim years As Double Dim month As Double Dim days As Double Dim weeks As Double Dim hours As Double Dim minutes As Double
If IsNull(dateTimeStart) = True Then Exit Function
Select Case years Case Is = 1 result = "Next year" Case Is > 1 result = "In " & years & " years" Case Is = -1 result = "Last Year" Case Is < -1 result = Abs(years) & " years ago" End Select
Select Case month Case 2 To 11 result = "In " & month & " months" Case Is = 1 result = "This month" Case Is = -1 result = "Last month" Case -11 To -2 result = Abs(month) & " months ago" End Select
Select Case days Case 2 To 6 result = "In " & days & " days" Case Is = 1 result = "Tomorrow" Case Is = -1 result = "Yesterday" Case -6 To -2 result = Abs(days) & " days ago" End Select
Select Case weeks Case 2 To 5 result = "In " & weeks & " weeks" Case Is = 1 result = "Next week" Case Is = -1 result = "Last week" Case -5 To -2 result = Abs(weeks) & " weeks ago" End Select
Select Case hours Case Is = 1 Select Case minutes - (Int(minutes / 60) * 60) Case Is = 0 result = "In an hour" Case Is = 1 result = "In an hour and one minute" Case Is = -1 result = "In an hour and one minute" Case 2 To 59 result = "In an hour and " & _ minutes - (Int(minutes / 60) * 60) & " minutes" Case 60 result = "In an hour" Case -59 To -2 result = "In an hour and " & _ minutes - (Int(minutes / 60) * 60) & " minutes" Case -60 result = "In an hour" End Select Case 2 To 23 Select Case minutes - (Int(minutes / 60) * 60) Case Is = 1 result = "In " & Int(minutes / 60) & _ " hours and one minute" Case Is = 0 result = "In " & Int(minutes / 60) & " hours" Case 2 To 59 result = "In " & Int(minutes / 60) & " hours, " & _ minutes - (Int(minutes / 60) * 60) & " minutes" Case Is = -1 result = "In " & Int(minutes / 60) & _ " hours and one minute" Case -59 To -2 result = "In " & Int(minutes / 60) & " hours, " & _ minutes - (Int(minutes / 60) * 60) & " minutes" Case Is = 60 result = "In " & Int(minutes / 60) & " hours" Case Is = -60 result = "In " & Int(minutes / 60) & " hours" End Select Case Is = -1 Select Case (Int(minutes / 60) * 60) - minutes + 60 Case Is = 0 result = "An hour ago" Case Is = 1 result = "An hour and 1 minute ago" Case 2 To 59 result = "An hour ago and " & _ (Int(minutes / 60) * 60) - minutes + 60 & _ " minutes ago" Case 60 result = "An hour ago" Case Is = -1 result = "An hour and 1 minute ago" Case -59 To -2 result = "An hour ago and " & _ (Int(minutes / 60) * 60) - minutes + 60 & _ " minutes ago" Case -60 result = "An hour ago" End Select Case -23 To -2 Select Case (Int(minutes / 60) * 60) - minutes + 60 Case Is = 0 result = Abs(Int(minutes / 60) + 1) & " hours ago" Case Is = 1 result = Abs(Int(minutes / 60) + 1) & _ " hours and one minute ago" Case 2 To 59 result = Abs(Int(minutes / 60) + 1) & " hours, " _ & (Int(minutes / 60) * 60) - minutes + 60 & _ " minutes ago" Case 60 result = Abs(Int(minutes / 60)) & " hours ago" Case Is = -1 result = Abs(Int(minutes / 60) + 1) & _ " hours and one minute ago" Case -59 To -2 result = Abs(Int(minutes / 60) + 1) & _ " hours, " & _ (Int(minutes / 60) * 60) - minutes + 60 & _ " minutes ago" Case -60 result = Abs(Int(minutes / 60) + 1) & " hours ago" End Select End Select
Select Case minutes Case 2 To 59 result = "In " & minutes & " minutes " Case Is = 1 result = "In 1 minute" Case Is = 0 result = "Now" Case Is = -1 result = "A minute ago" Case -59 To -2 result = Abs(minutes) & " minutes ago" End Select
QUOTATION MARKS WITHIN QUOTES In Access, you use the double-quote character around literal text, such as the Control Source of a text box: ="This text is in quotes." Often, you need quote marks inside quotes, e.g. when working with DLookup(). This article explains how. Basics You cannot just put quotes inside quotes like this: ="Here is a "word" in quotes" Error! Access reads as far as the quote before word, thinks that ends the string, and has no idea what to do with the remaining characters. The convention is to double-up the quote character if it is embedded in a string: ="Here is a ""word"" in quotes" It looks a bit odd at the end of a string, as the doubled-up quote character and the closing quote appear as 3 in a row: ="Here is a ""word""" Summary: Control Source property Result Explanation ="This is literal text." This is literal text. Literal text goes in quotes. ="Here is a "word" in quotes"
Access thinks the quote finishes before word, and does not know what to do with the remaining characters. ="Here is a ""word"" in quotes" Here is a "word" in quotes You must double-up the quote character inside quotes. ="Here is a ""word""" Here is a "word" The doubled- up quotes after word plus the closing quote gives you 3 in a row. Expressions Where this really matters is for expressions that involve quotes. For example, in the Northwind database, you would look up the City in the Customers table where the CompanyName is "La maison d'Asie": =DLookup("City", "Customers", "CompanyName = ""La maison d'Asie""") If you wanted to look up the city for the CompanyName in your form, you need to close the quote and concatenate that name into the string: =DLookup("City", "Customers", "CompanyName = """ & [CompanyName] & """") The 3-in-a-row you already recognise. The 4-in-a-row gives you just a closing quote after the company name. As literal text, it goes in quotes, which accounts for the opening and closing text. And what is in quotes is just the quote character - which must be doubled up since it is in quotes. As explained in the article on DLookup(), the quote delimiters apply only to Text type fields. The single-quote character can be used in some contexts for quotes within quotes. However, we do not recommend that approach: it fails as soon as a name contains an apostrophe (like the CompanyName example above.)
WHY CAN'T I APPEND SOME RECORDS? When you execute an append query, you may see a dialog giving reasons why some records were not inserted:
The dialog addresses four problem areas. This article explains each one, and how to solve them. Type conversion failure Access is having trouble putting the data into the fields because the field type does not match. For example, if you have a Number or Date field, and the data you are importing contains: - Unknown N/A these are not valid numbers or dates, so produce a "type conversion" error. In practice, Access has problems with any data that is is not in pure format. If the numbers have a Dollar sign at the front or contain commas or spaces between the thousands, the import can fail. Similarly, dates that are not in the standard US format are likely to fail. Sometimes you can work around these issues by importing the data into a table that has all Text type fields, and then typecasting the fields, using Val(), CVDate(), or reconstructing the dates with Left(), Mid(), Right(), and DateSerial(). For more on typecasting, see Calculated fields misinterpreted. Key violations The primary key must have a unique value. If you try to import a record where the primary key value is 9, and you already have a record where the primary key is 9, the import fails due to a violation of the primary key. You can also violate a foreign key. For example, if you have a field that indicates which category a record belongs to, you will have created a table of categories, and established a relationship so only valid categories are allowed in this field. If the record you are importing has an invalid category, you have a violation of the foreign key. You may have other unique indexes in your table as well. For example, an enrolment table might have a StudentID field (who is enrolled) and a ClassID field (what class they enrolled in), and you might create a unique index on the combination of StudentID + ClassID so you cannot have the same student enrolled twice in the one class. Now if the data you are importing has an existing combination of Student and Class, the import will fail with a violation of this unique index. Lock violations Lock violations occur when the data you are trying to import is already in use. To solve this issue, make sure no other users have this database open, and close all other tables, queries, forms, and reports. If the problem persists, Make sure you have set Default Record Locking to "No Locks" under File (Office Button) | Options | Advanced (Access 2007 or later), or in earlier versions: Tools | Options | Advanced. Validation rule violations There are several places to look to solve for this one: There is something in the Validation Rule of one of the fields, and the data you are trying to add does not meet this rule. The Validation Rule of each field is in the lower pane of table design window. There is something in the Validation Rule of the table, and the data you are trying to add does not meet this rule. The Validation Rule of the table is in the Properties box. The field has the Required property set to Yes, but the data has no value for that field. The field has the Allow Zero Length property set to No (as it should), but the data contains zero-length-strings instead of nulls. If none of these apply, double-check the key violations above. Still stuck? If the problem data is not obvious, you might consider clicking Yes in the dialog shown at the beginning of this article. Access will create a table named Paste Errors or Import Errors or similar. Examining the specific records that failed should help to identify what went wrong. After fixing the problems, you can then import the failed records, or restore a backup of the database and run the complete import again.
ROUNDING IN ACCESS To round numbers, Access 2000 and later has a Round() function built in. For earlier versions, get this custom rounding function by Ken Getz. The built-in function Use the Round() function in the Control Source of a text box, or in a calculated query field. Say you have this expression in the Field row in query design: Tax: [Amount] * [TaxRate] To round to the nearest cent, use: Tax: Round([Amount] * [TaxRate], 2) Rounding down To round all fractional values down to the lower number, use Int(): Int([MyField]) All these numbers would then be rounded down to 2: 2.1, 2.5, 2.8, and 2.99. To round down to the lower cent (e.g. $10.2199 becomes $10.21), multiply by 100, round, and then divide by 100: Int(100 * [MyField]) / 100 Be aware of what happens when negative values are rounded down: Int(-2.1) yields -3, since that is the integer below. To round towards zero, use Fix() instead of Int(): Fix(100 * [MyField]) / 100 Rounding up To round upwards towards the next highest number, take advantage of the way Int() rounds negative numbers downwards, like this: - Int( - [MyField]) As shown above, Int(-2.1) rounds down to -3. Therefore this expression rounds 2.1 up to 3. To round up to the higher cent, multiply by -100, round, and divide by -100: Int(-100 * [MyField]) / -100 Round to nearest 5 cents To round to the nearest 5 cents, multiply the number by 20, round it, and divide by 20: Round(20 * [MyField], 0) / 20 Similarly, to round to the nearest quarter, multiply by 4, round, and divide by 4: Round(4 * [MyField], 0) / 4 Round to $1000 The Round() function in Excel accepts negative numbers for the number of decimal places, e.g. Round(123456, -3) rounds to the nearest 1000. Unfortunately, the Access function does not support this. To round to the nearest $1000, divide by 1000, round, and multiply by 1000. Example: 1000 * Round([Amount] / 1000, 0) To round down to the lower $1000, divide by 1000, get the integer value, and multiply by 1000. Example: 1000 * Int([Amount] / 1000) To round up to the higher $1000, divide by 1000, negate before you get the integer value. Example: -1000 * Int( [Amount] / -1000) To round towards zero, use Fix() instead of Int(). Alternatively, Ken Getz' custom rounding function behaves like the Excel function. Why round? There is a Decimal Places property for fields in a table/query and for text boxes on a form/report. This property only affects the way the field is displayed, not the way it is stored. The number will appear to be rounded, but when you sum these numbers (e.g. at the foot of a report), the total may not add up correctly. Round the field when you do the calculation, and the field will sum correctly. This applies to currency fields as well. Access displays currency fields rounded to the nearest cent, but it stores the value to the hundredth of a cent (4 decimal places.) Bankers rounding The Round() function in Access uses a bankers rounding. When the last significant digit is a 5, it rounds to the nearest even number. So, 0.125 rounds to 0.12 (2 is even), whereas 0.135 rounds to 0.14 (4 is even.) The core idea here is fairness: 1,2,3, and 4 get rounded down. 6,7,8, and 9 get rounded up. 0 does not need rounding. So if the 5 were always rounded up, you would get biased results - 4 digits being rounded down, and 5 being rounded up. To avoid this, the odd one out (the 5) is rounded according to the previous digit, which evens things up. If you do not wish to use bankers rounding, get Ken Getz' custom function (linked above.) Floating point errors Fractional values in a computer are typically handled as floating point numbers. Access fields of type Double or Single are this type. The Double gives about 15 digits of precision, and the Single gives around 8 digits (similar to a hand-held calculator.) But these numbers are approximations. Just as 1/3 requires an infinite number of places in the decimal system, most floating point numbers cannot be represented precisely in the binary system. Wikipedia explains the accuracy problems you face when computing floating point numbers. The upshot is that marginal numbers may not round the way you expect, due to the fact that the actual values and the display values are not the same. This is especially noticeable when testing bankers rounding. One way to avoid these issues is to use a fixed point or scalar number instead. The Currency data type in Access is fixed point: it always stores 4 decimal places. For example, open the Immediate Window (Ctrl+G), and enter: ? Round(CCur(.545),2), Round(CDbl(.545),2) The Currency type (first one) yields 0.54, whereas the Double yields 0.55. The Currency rounds correctly (towards the even 4); the floating point type (Double) is inaccurate. Similarly, if you try 8.995, the Currency correctly rounds up (towards the even 0), while the Double rounds it down (wrong.) Currency copes with only 4 decimal places. Use the scalar type Decimal if you need more places after the decimal point. Rounding dates and times Note that the Date/Time data type in Access is a special kind of floating point type, where the fractional part represents the time of day. Consequently, Date/Time fields that have a time component are subject to floating point errors as well. The function below rounds a date/time value to the specified number of seconds. For example, to round to the nearest half hour (30 * 60 seconds), use: =RoundTime([MyDateTimeField], 1800) Public Function RoundTime(varTime As Variant, Optional ByVal lngSeconds As Long = 900&) As Variant
'Purpose: Round a date/time value to the nearest number of seconds 'Arguments: varTime = the date/time value ' lngSeconds = number of seconds to round to. ' e.g. 60 for nearest minute, ' 600 for nearest 10 minutes, ' 3600 for nearest hour, ' 86400 for nearest day. 'Return: Rounded date/time value, or Null if no date/time passed in. 'Note: lngSeconds must be between 1 and 86400. ' Default rounds is nearest 15 minutes. Dim lngSecondsOffset As Long
RoundTime = Null 'Initialize to return Null. If Not IsError(varTime) Then If IsDate(varTime) Then If (lngSeconds < 1&) Or (lngSeconds > 86400) Then lngSeconds = 1& End If lngSecondsOffset = lngSeconds * CLng(DateDiff("s", #12:00:00 AM#, TimeValue(varTime)) / lngSeconds) RoundTime = DateAdd("s", lngSecondsOffset, DateValue(varTime)) End If End If End Function
Duplicate the record in form and subform The example below shows how to duplicate the record in the main form, and also the related records in the subform. Change the highlighted names to match the names of your fields, table, and subform control. To use the code as is, add a command button to the Orders form in Northwind. The code Private Sub cmdDupe_Click() 'On Error GoTo Err_Handler 'Purpose: Duplicate the main form record and related records in the subform. Dim strSql As String 'SQL statement. Dim lngID As Long 'Primary key value of the new record.
'Save any edits first If Me.Dirty Then Me.Dirty = False End If
'Make sure there is a record to duplicate. If Me.NewRecord Then MsgBox "Select the record to duplicate." Else 'Duplicate the main record: add to form's clone. With Me.RecordsetClone .AddNew !CustomerID = Me.CustomerID !EmployeeID = Me.EmployeeID !OrderDate = Date 'etc for other fields. .Update
'Save the primary key value, to use as the foreign key for the related records. .Bookmark = .LastModified lngID = !OrderID
'Duplicate the related records: append query. If Me.[Orders Subform].Form.RecordsetClone.RecordCount > 0 Then strSql = "INSERT INTO [Order Details] ( OrderID, ProductID, Quantity, UnitPrice, Discount ) " & _ "SELECT " & lngID & " As NewID, ProductID, Quantity, UnitPrice, Discount " & _ "FROM [Order Details] WHERE OrderID = " & Me.OrderID & ";" DBEngine(0)(0).Execute strSql, dbFailOnError Else MsgBox "Main record duplicated, but there were no related records." End If
'Display the new duplicate. Me.Bookmark = .LastModified End With End If
Exit_Handler: Exit Sub
Err_Handler: MsgBox "Error " & Err.Number & " - " & Err.Description, , "cmdDupe_Click" Resume Exit_Handler End Sub
Explanation The code first saves any edits in progress, and checks that the form is not at a new record. The AddNew assigns a buffer for the new record. We then copy some sample fields from the current form into this buffer, and save the new record with Update. Ensuring the new record is current (by setting the recordset's bookmark to the last modified one), we store the new primary key value in a variable, so we can use it in the related records. Then, we check that there are records in the subform, and duplicate them with an append query statement. The query selects the same child records shown in the subform, and appends them to the same table with the new OrderID. If you are not sure how to create this query statement for your database, you can see an example by mocking up a query and switching to SQL view (View menu, in query design.) So why did we use AddNew in the main form, but an append query statement to duplicate the subform records? AddNew gives us the new primary key value, which we needed to create the related records. The append query creates all related records in one step. We are able to move to the new record in the main form without having to Requery.
ASSIGN DEFAULT VALUES FROM THE LAST RECORD Sometimes you need to design a form where many fields will have similar values to the last record entered, so you can expedite data entry if all controls carry data over. There are two ways to achieve this: Set the Default Value of each control so they offer the same value as soon as you move into the new record. Use the BeforeInsert event of the form so they all inherit the same values as soon as the user starts typing in the new record. The first is best suited to setting a particular field. Dev Ashish explains the process here: Carry current value of a control to new records. This article takes the second approach, which has these advantages: Since the new record is blank until the first keystroke, the user is not confused about whether this is a new or existing record. Values are inserted even for the first entry after the form is opened (assuming there are records.) The code is generic (does not need to refer to each control by name), so can be reused for any form. The default value is not applied to the control that the user is trying to type into when they start the new record. Note: The code works with Access 2007 and later if the form does not contain controls bound to multi-valued fields (including Attachment.) The steps To implement this tip in your form: Open a new module. In Access 95 - 2003, click the Modules tab of the Database window and click New. In Access 2007 and later, click the Create ribbon, drop-down the right-most icon in the Other group and choose Module. Copy the code below, and paste into the new module. Verify that Access understands the code by choosing Compile from the Debug menu. Save it with a name such as Module1. Close the code window. Open your form in design view. Open the Properties sheet, making sure you are looking at the properties of the Form (not those of a text box.) On the Event tab of the Properties box, set the Before Insert property to: [Event Procedure] Click the Build button (...) beside this Property. Access opens the code window. Set up the code like this:
Private Sub Form_BeforeInsert(Cancel As Integer) Dim strMsg As String Call CarryOver(Me, strMsg) If strMsg <> vbNullString Then MsgBox strMsg, vbInformation End If End Sub Save. Repeat steps 5 - 9 for any other forms. If there are specific fields you do not wish to carry over, add the name of the controls in quotes inside the brackets, with commas between them. For example to leave the Notes and EmployeeID fields blank, use: Call CarryOver(Me, strMsg, "Notes", "EmployeeID") The code is intelligent enough not to try to duplicate your AutoNumber or calculated fields, so you do not need to explicitly exclude those. Similarly, if the form is a subform, any fields named in LinkChildFields will be the same as the record we are copying from, so you do not need to explicitly exclude those either. If you do not wish to see any error messages, you could just set the Before Insert property of the form to: =CarryOver([Form], "") The code Here is the code for the generic module (Step 2 above.)
Public Function CarryOver(frm As Form, strErrMsg As String, ParamArray avarExceptionList()) As Long
On Error GoTo Err_Handler 'Purpose: Carry over the same fields to a new record, based on the last record in the form. 'Arguments: frm = the form to copy the values on. ' strErrMsg = string to append error messages to. ' avarExceptionList = list of control names NOT to copy values over to. 'Return: Count of controls that had a value assigned. 'Usage: In a form's BeforeInsert event, excluding Surname and City controls: ' Call CarryOver(Me, strMsg, "Surname", City") Dim rs As DAO.Recordset 'Clone of form. Dim ctl As Control 'Each control on form. Dim strForm As String 'Name of form (for error handler.) Dim strControl As String 'Each control in the loop Dim strActiveControl As String 'Name of the active control. Don't assign this as user is typing in it. Dim strControlSource As String 'ControlSource property. Dim lngI As Long 'Loop counter. Dim lngLBound As Long 'Lower bound of exception list array. Dim lngUBound As Long 'Upper bound of exception list array. Dim bCancel As Boolean 'Flag to cancel this operation. Dim bSkip As Boolean 'Flag to skip one control. Dim lngKt As Long 'Count of controls assigned.
'Must not assign values to the form's controls if it is not at a new record. If Not frm.NewRecord Then bCancel = True strErrMsg = strErrMsg & "Cannot carry values over. Form '" & strForm & "' is not at a new record." & vbCrLf End If 'Find the record to copy, checking there is one. If Not bCancel Then Set rs = frm.RecordsetClone If rs.RecordCount <= 0& Then bCancel = True strErrMsg = strErrMsg & "Cannot carry values over. Form '" & strForm & "' has no records." & vbCrLf End If End If
If Not bCancel Then 'The last record in the form is the one to copy. rs.MoveLast 'Loop the controls. For Each ctl In frm.Controls bSkip = False strControl = ctl.Name 'Ignore the active control, those without a ControlSource, and those in the exception list. If (strControl <> strActiveControl) And HasProperty(ctl, "ControlSource") Then For lngI = lngLBound To lngUBound If avarExceptionList(lngI) = strControl Then bSkip = True Exit For End If Next If Not bSkip Then 'Examine what this control is bound to. Ignore unbound, or bound to an expression. strControlSource = ctl.ControlSource If (strControlSource <> vbNullString) And Not (strControlSource Like "=*") Then 'Ignore calculated fields (no SourceTable), autonumber fields, and null values. With rs(strControlSource) If (.SourceTable <> vbNullString) And ((.Attributes And dbAutoIncrField) = 0&) _ And Not (IsCalcTableField(rs(strControlSource)) Or IsNull(.Value)) Then If ctl.Value = .Value Then 'do nothing. (Skipping this can cause Error 3331.) Else ctl.Value = .Value lngKt = lngKt + 1& End If End If End With End If End If End If Next End If
CarryOver = lngKt
Exit_Handler: Set rs = Nothing Exit Function
Err_Handler: strErrMsg = strErrMsg & Err.Description & vbCrLf Resume Exit_Handler End Function
Private Function IsCalcTableField(fld As DAO.Field) As Boolean 'Purpose: Returns True if fld is a calculated field (Access 2010 and later only.) On Error GoTo ExitHandler Dim strExpr As String
strExpr = fld.Properties("Expression") If strExpr <> vbNullString Then IsCalcTableField = True End If
ExitHandler: End Function
Public Function HasProperty(obj As Object, strPropName As String) As Boolean 'Purpose: Return true if the object has the property. Dim varDummy As Variant
On Error Resume Next varDummy = obj.Properties(strPropName) HasProperty = (Err.Number = 0) End Function
How it works You can use the code without understanding how it works, but the point of this website is help you understand how to use Access. The arguments The code goes in a general module, so it can be used with any form. Passing in the form as an argument allows the code to do anything that you could with with Me in the form's own module. The second argument is a string that this routine can append any error messages to. Since the function does not pop up any error messages, the calling routine can then decide whether it wants to display the errors, ignore them, pass them to a higher level function, or whatever. I find this approach very useful for generic procedures, especially where they can be called in various ways. The final argument accepts an array, so the user can type as many literals as they wish, separated by commas. The ParamArray keyword means any number of arguments to be passed in. They arrive as a variant array, so the first thing the function does is to use LBound() to get the lower array bound (usually zero) and UBound() to get the upper array bound - standard array techniques. The checks The code checks that the form is at a new record (which also verifies it is a bound form). Then it checks that there is a previous record to copy, and moves the form's RecordsetClone to the last record - the one we want to copy the field values from. It then loops through all the controls on the form. The control's Name can be different from its ControlSource, so it is the ControlSource we must match to the field in the RecordsetClone. Some controls (labels, lines, ...) have no ControlSource. Others may be unbound, or bound to an expression, or bound to a calculated query field, or bound to an AutoNumber field - all cases where no assignment can be made. The code tests for these cases like this: Control Action Controls with no ControlSource (command buttons, labels, ...) The HasProperty() function tests for this property, recovers from any error, and informs the main routine whether to skip the control. The control the user is typing into (so we do not overwrite the entry) Compare the control's Name with Screen.ActiveControl.Name. Controls named in the exception list Compare the control's Name with names in the exception list array. Unbound controls Test if the ControlSource property is a zero-length string. Controls bound to an expression (cannot be assigned a value) Test if the ControlSource starts with "=". Controls bound to a calculated query field In the form's RecordsetClone, the Field has a SourceTable property. For fields created in the query, this property is is a zero-length string. Controls bound to a calculated table field In the form's RecordsetClone, the Field has an Expression property that is not just a zero-length string. Controls bound to an AutoNumber field In the form's RecordsetClone, the Attributes property of the Field will have thedbAutoIncrField bit set. Fields that were Null in the record we are copying from We bypass these, so Access can still apply any DefaultValue. If the control has not been culled along the way, we assign it the Value of the field in the form's RecordsetClone, and increment our counter. The return value Finally, the function returns the number of controls that were assigned a value, in case the calling routine wants to know. If an error occurs, we return information about the error in the second argument, so the calling routine can examine or display the error message to the user.
MANAGING MULTIPLE INSTANCES OF A FORM Want to compare two or more clients on screen at the same time? Though rarely used, the feature was introduced in Access 97. The New keyword creates an instance of a form with a couple of lines of code, but managing the various instances takes a little more effort. A sample database demonstrates the code in this article. Creating Instances A simple but inadequate approach is to place a command button on the form itself. For a form named frmClient with a command button named cmdNewInstance, you need just 5 lines of code in the forms module:
Dim frmMulti As Form Private Sub cmdNewInstance_Click() Set frmMulti = New Form_frmClient frmMulti.SetFocus End Sub
Open the form and click the command button. A second client form opens on top of the first, and can display a different client. The second instance also has the command button, so you can open a third instance, and so on. However, these forms are not independent of each other. Close the first one, and they all close. Click the New Instance button on the second one, and the third and fourth instances are replaced. Since the object variable frmMulti is declared in the class module of the form, each instance can support only one subsequent instance, so closing a form or reassigning this variable destroys all subsequent instances that may be open. You also have difficulties keeping track of an instance. The Forms collection will have multiple entries with the same name so Forms.frmClient is inadequate. The index number of theForms collection such as Forms(3) wont work either: these numbers change as forms are opened and closed. Managing Instances To solve the dependencies, create a collection in another module. Add to the collection as each new instance is opened, and remove from the collection when it is closed. Each instance is now completely independent of the others, depending only on your collection for survival. To solve the problem of the instances identity, use its hWnd the unique handle assigned to each window by the operating system. This value should be constant for the life of the window, though the Access 97 Help File warns: Caution: Because the value of this property can change while a program is running, don't store the hWnd property value in a public variable. Presumably, this comment refers to reusing this value when a form may be closed and reopened. The following example uses the hWnd of the instance as the key value in the collection. The first line below creates the collection where we can store independent instances of our form. The function OpenAClient() opens an instance and appends it to our collection. This code is in the basPublic module of the sample database:
Public clnClient As New Collection 'Instances of frmClient. Function OpenAClient() 'Purpose: Open an independent instance of form frmClient. Dim frm As Form
'Open a new instance, show it, and set a caption. Set frm = New Form_frmClient frm.Visible = True frm.Caption = frm.Hwnd & ", opened " & Now()
'Append it to our collection. clnClient.Add Item:=frm, Key:=CStr(frm.Hwnd)
Set frm = Nothing End Function
Function CloseAllClients() 'Purpose: Close all instances in the clnClient collection. 'Note: Leaves the copy opened directly from database window/nav pane. Dim lngKt As Long Dim lngI As Long
lngKt = clnClient.Count For lngI = 1 To lngKt clnClient.Remove 1 Next End Function
The second function CloseAllClients() demonstrates how to close these instances by removing them from our collection. But if the user closes an instance with the normal interface, we need to remove that instance from our collection. Thats done in the Close event of form frmClient like this:
Private Sub Form_Close() 'Purpose: Remove this instance from clnClient collection. Dim obj As Object 'Object in clnClient
Dim blnRemove As Boolean 'Flag to remove it.
'Check if this instance is in the collection. For Each obj In clnClient If obj.Hwnd = Me.Hwnd Then blnRemove = True Exit For End If Next
'Deassign the object and remove from collection. Set obj = Nothing If blnRemove Then clnClient.Remove CStr(Me.Hwnd) End If End Sub
ROLLING DATES BY PRESSING "+" OR "-" Some commercial programs (Tracker, Quicken, etc) allow the user to press "+" or "-" to increment or decrement a date without the hassle of selecting the day part of the field and entering a new value. This is especially useful in fields where the default date offered could be a day or two different from the date desired. To provide this functionality in Access, attach this Event Procedure to the KeyPress event of your control. Select Case KeyAscii Case 43 ' Plus key KeyAscii = 0 Screen.ActiveControl = Screen.ActiveControl + 1 Case 45 ' Minus key KeyAscii = 0 Screen.ActiveControl = Screen.ActiveControl - 1 End Select When any alphanumeric key is pressed, Access passes its ASCII value to your event procedure in the variable KeyAscii. The code examines this value, and acts only if the value is 43 (plus) or 45 (minus). It destroys the keystroke (so it is not displayed) by setting the value of KeyAscii to zero. The active control is then incremented or decremented. This idea is not limited to dates, or even textboxes. The code can be adapted for other keystrokes as required. Use the KeyDown event to distinguish between the two plus keys (top row and numeric keypad), or to trap control keys such as {Esc}. Anyone feel like reprogramming an entire keyboard?
RETURN TO THE SAME RECORD NEXT TIME FORM IS OPENED When a form is opened, you may like to automatically load the most recently edited record. To do so: Create a table to save the record's Primary Key value between sessions; Use the form's Unload event to save the current record's ID; Use the form's Load event to find that record again. As an example, take a form that has CustomerID as the primary key field. 1. Create a table to save the Primary Key value between sessions Create a table with these 3 fields: Field Name Type Description Variable Text, 20 Holds the variable name. Mark as primary key. Value Text, 80 Holds the value to be returned. Description Text, 255 What this variable is used for/by. Save this table with the name "tblSys". You may care to mark this as a hidden table. 2. Use the form's UnLoad event to save the record's ID. Set the form's On Unload property to [Event Procedure], and add the following code. It finds (or creates) a record in tblSys where the field Variable contains "CustomerIDLast", and stores the current CustomerID in the field called Value.
Sub Form_Unload (Cancel As Integer) Dim rs As DAO.Recordset
If Not IsNull(Me.CustomerID) Then Set rs = CurrentDb().OpenRecordset("tblSys", dbOpenDynaset) With rs .FindFirst "[Variable] = 'CustomerIDLast'" If .NoMatch Then .AddNew 'Create the entry if not found. ![Variable] = "CustomerIDLast" ![Value] = Me.CustomerID ![Description] = "Last customerID, for form " & Me.Name .Update Else .Edit 'Save the current record's primary key. ![Value] = Me.CustomerID .Update End If End With rs.Close End If Set rs = Nothing End Sub
3. Use the form's Load event to find that record again. Set the form's On Load property to [Event Procedure], and add the following code. It performs these steps: locates the record in tblSys where the Variable field contains "CustomerIDLast"; gets the last stored CustomerID from the Value field; finds that CustomerID in the form's clone recordset; moves to that record by setting the form's Bookmark.
Sub Form_Load() Dim varID As Variant Dim strDelim As String 'Note: If CustomerID field is a Text field (not a Number field), remove single quote at start of next line. 'strDelim = """"
varID = DLookup("Value", "tblSys", "[Variable] = 'CustomerIDLast'") If IsNumeric(varID) Then With Me.RecordsetClone .FindFirst "[CustomerID] = " & strDelim & varID & strDelim If Not .NoMatch Then Me.Bookmark = .Bookmark End If End With End If End Sub
UNBOUND TEXT BOX: LIMITING ENTRY LENGTH Access automatically prevents you entering too much text if a control is bound to a field. Unbound controls can be limited with the Input Mask - one "C" for each possible character. However, the input mask has side effects such as appending underscores to the Text property and making it difficult to insert text into the middle of an entry. For a cleaner result, use a combination of the control's KeyPress and Change events. Here's how. Paste the two "subs" from the end of this article into a module. Save. Call LimitKeyPress() in your text box's KeyPress event. For example, to limit a control named "City" to 40 characters, its KeyPress event procedure is: Call LimitKeyPress(Me.City, 40, KeyAscii) Call LimitChange() in your text box's Change event. For the same example, the Change event procedure is: Call LimitChange(Me.City, 40) Repeat steps 2 and 3 for other unbound text/combo boxes in your application.
Why Both Events? When the Change event fires, the text is already in the control. There is no way to retrieve the last acceptable entry if it's too long. You could create a variable for each control, store its last known good entry, and restore that value if the Change event finds the text is too long. However, maintaining such a variable would be a nightmare: can you guarantee to initialize every variable to the control's DefaultValue in the form's Load event, update its variable on every occasion that a control is written to programmatically, effectively document this to ensure no other programmer writes to the control without updating the variable, etc.? The KeyPress event does not have these problems. You can simply discard unacceptable keystrokes, leaving the text in the control as it was. However, this event alone is inadequate: a user can paste text into the control without firing the KeyPress, KeyDown, or KeyUp events. We need both events. Block unacceptable keystrokes in the KeyPress event before they reach the control, and truncate entries in the Change event if the user pastes in too much text.
How Do These Procedures Work? LimitKeyPress() When a user types a character into the control, KeyPress is triggered. The value of KeyAscii tells you the character typed. Setting KeyAscii to zero destroys the keystroke before it reaches the text box. The middle line of this procedure does this, after checking two conditions. The first "If ..." reads the number of characters already in the text box (its Text property). If any characters are selected, they are replaced when a character is typed, so we subtract the length of the selection (its SelLength property). If Access happens to be in Over-Type mode and the cursor is in the middle of the text, a character is automatically selected so over-type still works. Non-text keystrokes (such as Tab, Enter, PgDn, Home, Del, Alt, Esc) do not trigger the KeyPress event. The KeyDown and KeyUp events let you manage those. However, BackSpace does trigger KeyPress. The second "If ..." block allows BackSpace to be processed normally. LimitChange() This procedure cleans up the case where the user changes the text in the control without firing the KeyPress event, such as by pasting. It compares the length of the text in the control (ctl.Text) to the maximum allowed ( iMaxLen). If it is too great, the procedure does 3 things: it notifies the user (MsgBox), truncates the text (Left()), and moves the cursor to the end of the text (SelStart).
The Code Paste these into a module. If you do not wish to use the LogError() function, replace the third last line of both procedures with: MsgBox "Error " & Err.Number & ": " & Err.Description
Sub LimitKeyPress(ctl As Control, iMaxLen As Integer, KeyAscii As Integer) On Error GoTo Err_LimitKeyPress ' Purpose: Limit the text in an unbound text box/combo. ' Usage: In the control's KeyPress event procedure: ' Call LimitKeyPress(Me.MyTextBox, 12, KeyAscii) ' Note: Requires LimitChange() in control's Change event also.
If Len(ctl.Text) - ctl.SelLength >= iMaxLen Then If KeyAscii <> vbKeyBack Then KeyAscii = 0 Beep End If End If
Exit_LimitKeyPress: Exit Sub
Err_LimitKeyPress: Call LogError(Err.Number, Err.Description, "LimitKeyPress()") Resume Exit_LimitKeyPress End Sub
Sub LimitChange(ctl As Control, iMaxLen As Integer) On Error GoTo Err_LimitChange ' Purpose: Limit the text in an unbound text box/combo. ' Usage: In the control's Change event procedure: ' Call LimitChange(Me.MyTextBox, 12) ' Note: Requires LimitKeyPress() in control's KeyPress event also.
If Len(ctl.Text) > iMaxLen Then MsgBox "Truncated to " & iMaxLen & " characters.", vbExclamation, "Too long" ctl.Text = Left(ctl.Text, iMaxLen) ctl.SelStart = iMaxLen End If
Exit_LimitChange: Exit Sub
Err_LimitChange: Call LogError(Err.Number, Err.Description, "LimitChange()") Resume Exit_LimitChange End Sub
PROPERTIES AT RUNTIME: FORMS In Access version 1, only a few properties such as Visible and Enabled were editable when the application was running. In later versions, only a handful of properties are not available at runtime, so you can use the Current event of a form or the Format event of a report section to conditionally alter the formatting and layout of the data, depending on its content. For example, to highlight those who owe you large amounts of money, you could add this in a form's Current event: With Me.AmountDue If .Value > 500 Then .Forecolor = 255 .Fontbold = True .Fontsize = 14 .Height = 400 Else .Forecolor = 0 .Fontbold = False .Fontsize = 10 .Height = 300 End If End With
Some of the changes you can perform are rather radical, such as changing the record source of a form while it is running! You might do this to change the sort order, or - with astute use of SQL - to reduce network traffic from a remote server. In addition, some controls have properties which do not appear in the "Properties" list at all, since they are available only at runtime. For example, combo boxes have a "column()" property which refers to the data in the columns of the control. Picture a combo box called cboClient with 3 columns: ID, Surname, Firstname. When not dropped down, only the ID is visible, so you decide to be helpful and add a read-only textbox displaying the name. DLookup() will work, but it is much more efficient to reference the data already in the combo bybinding your textbox to the expression: = [cboClient].[Column](2) & " " & [cboClient].[Column](1)
HIGHLIGHT THE REQUIRED FIELDS, OR THE CONTROL THAT HAS FOCUS
Would you like your forms to automatically identify the fields where an entry is required? How about highlighting the control that has the focus, so you don't have to search for the cursor? This utility automatically does both in any form in Form view (not Continuous), just by setting a property. In the screenshot (right), Title is highlighted as the current field (yellow), and the name fields are required (red background, and bold label with a star.) Modify the colors to whatever style suits you. Implementation To use this in your database: Download the example database (24 kb zipped, for Access 2000 or later.) Copy the module named ajbHighlight into your database. Widen the labels attached to your controls (to handle the star and bolding.) Set the On Load property of your form to: =SetupForm([Form]) Do not substitute the name of your form in the expression above, i.e. use the literal [Form] as shown. Options To highlight the required fields only, use: =SetupForm([Form], 1) To highlight the control with focus only, use: =SetupForm([Form], 2) If your form's OnLoad property is set to [Event Procedure] add this line to the code: Call SetupForm(Me) Change the color scheme by assigning different values to the constants at the top of the module. mlngcFocusBackColor defines the color when a control gains focus.mlngcRequiredBackColor defines the color for required fields. Use RGB values (red, green, blue.) Note that: In Datasheet view, only the asterisk shows (over the column heading) In Continuous form view (where you typically have not attached labels), only the background color shows. (You could modify the code with the CaptionFromHeader() function from the FindAsUType utility, so as to bold the labels in the Form Header over the columns.) Note that the labels will not be bolded or have the star added if they are not attached to the controls. To reattach a label in form design view, cut it to clipboard, select the control to attach it to, and paste. Limitations The code highlights only text boxes, combo boxes, and list boxes. A control will not highlight if it already has something in its On Got Focus or On Lost Focus properties. Use OnEnter or OnExit for the existing code. How it works You can use the code without understanding how it works: this explanation is for those who want to learn how it works, or modify what it does. The main function SetupForm() accepts two arguments: a reference to the form you are setting up, and an integer indicating what parts you want set up. The integer is optional, and defaults to all bits on (except the sign.) We are actually only using the first two bits (for required and focus-color); you can use the remaining bits for other things you want to set up on your form. SetupForm() examines the bits, and calls separate functions to handle the required and focus-color issues. Highlighting the control with focus The OnGotFocus event fires when a control get focus, and its OnLostFocus event when focus moves away. We can therefore use these events to highlight (by setting its BackColor) and restore it. But we needs these events to fire for each control that can get focus. SetupFocusColor() assigns these properties for us when the form loads. So, SetupFocusColor() loops through each control on the form. It looks at the ControlType property, and skip anything other than a text box, combo, or list box, and controls that are already using OnGotFocus or OnLostFocus. It then sets property values this (using Text0 as an example): Property Setting Comment On Got Focus: =Hilight([Text0], True) Text0 will be highlighted when it gets focus. The square brackets cope with odd field names. On Lost Focus: =Hilight([Text0], False) Text0 will be restored to normal when it loses focus. We will look in the Tag property to find what that is. Tag: UsualBackColor=13684991 This is the color to restore it to when it loses focus. We append (after a semicolon) if Tag contains something. Assigning these properties automatically when the form opens makes it easier to design and maintain. Now, when any of these controls receives focus, it calls Hilight(), passing in a reference to itself, and a True flag. When it loses focus, it calls Hilight() with a False flag. If the flag is True (i.e. the control is gaining focus), Hilight() simply sets its BackColor to the value specified in the constant mstrcTagBackColor. You can set that value to any number you wish at the top of the module. Just use any valid RGB (red-green-blue) value. If the flag is False (i.e. the control is losing focus), Hilight() needs to set it back to its old color. Our initialization SetupFocusColor() stored the usual background color for the control in its Tag property. Tag could be used for other things as well (typically separated by semicolons), so we call ReadFromTag() to parse the value from the tag. If we get a valid number, we assign that to the BackColor. Otherwise (e.g. if some less polite code overwrote the Tag), we assign the most likely background color (white.) Highlighting required fields SetupRequiredFields() is the function that provides the formatting for fields that are required. Again, we loop through the controls, ignoring anything other than text box, combo, or list box. We also ignore it if its Control Source is unbound (zero-length string), or bound to an expression (starts with =.) Otherwise the Control Source must be the name of a field, so we look at that field in the Recordset of the form. If the field's Required property is true, we will highlight it. We also check if the field's Validation Rule includes a statement that it is not null: some developers prefer this to the Required property, as it allows them to use the field's Validation Rule to give a custom message. If we determined that the field is required, we set the BackColor of the control to the color specified in the constant mlngcRequiredBackColor. Then we call MarkAttachedLabel() to format its label as well. The reason for using a separate function here is that the control may not have an attached label, so an error is likely. It's simplest to handle that error in a separate function. If there is an attached label, it will be the first member of the Controls collection of our control Controls(0). If there is no attached label, the error handler jumps out. Otherwise we add the asterisk to its Caption (unless it already has one), and sets it to bold. Using bold looks good on continuous forms but does not show on datasheets. The asterisk does show in the Column Heading in datasheet view. You can use whatever formatting suits you.
COMBOS WITH TENS OF THOUSANDS OF RECORDS Combos become unworkable with many thousands of records, even many hundreds in Access 2. By loading records into the combo only after the user has typed the first three or four characters, you can use combos far beyond their normal limits, even with the AutoExpand property on. This is the idea: Leave the combo's RowSource property blank. Create a function that assigns the RowSource after a minimum number of characters has been typed. Only entries matching these initial characters are loaded, so the combo's RowSource never contains more than a few hundred records. Call this function in the combo's Change event, and the form's Current event.
Example: Look up Postal Codes from Suburb For this example you need a table named Postcodes, with fields Suburb, State, Postcode. You may be able to create this table from downloaded data, for example postcodes for Australia. Make sure all three fields are indexed. You also need a combo with these properties: Name Suburb RowSource
BoundColumn 1 ColumnCount 3
Step 1: Paste this into the General Declarations section of your form?s module:
Dim sSuburbStub As String Const conSuburbMin = 3 Function ReloadSuburb(sSuburb As String) Dim sNewStub As String ' First chars of Suburb.Text
sNewStub = Nz(Left(sSuburb, conSuburbMin),"") ' If first n chars are the same as previously, do nothing. If sNewStub <> sSuburbStub Then If Len(sNewStub) < conSuburbMin Then 'Remove the RowSource Me.Suburb.RowSource = "SELECT Suburb, State, Postcode FROM Postcodes WHERE (False);" sSuburbStub = "" Else 'New RowSource Me.Suburb.RowSource = "SELECT Suburb, State, Postcode FROM Postcodes WHERE (Suburb Like """ & _ sNewStub & "*"") ORDER BY Suburb, State, Postcode;" sSuburbStub = sNewStub End If End If End Function
Step 2: In the form's Current event procedure, enter this line: Call ReloadSuburb(Nz(Me.Suburb, ""))
Step 3: In the combo's Change event procedure, you could also use a single line. The code below illustrates how to do a little more, blocking initial spaces, and forcing "Mt " to "Mount ": Dim cbo As ComboBox ' Suburb combo. Dim sText As String ' Text property of combo.
Set cbo = Me.Suburb sText = cbo.Text Select Case sText Case " " ' Remove initial space cbo = Null Case "MT " ' Change "Mt " to "Mount ". cbo = "MOUNT " cbo.SelStart = 6 Call ReloadSuburb(sText) Case Else ' Reload RowSource data. Call ReloadSuburb(sText) End Select Set cbo = Nothing
Step 4: To assign the State and Postcode, add this code to the combo's AfterUpdate event procedure: Dim cbo As ComboBox Set cbo = Me.Suburb If Not IsNull(cbo.Value) Then If cbo.Value = cbo.Column(0) Then If Len(cbo.Column(1)) > 0 Then Me.State = cbo.Column(1) End If If Len(cbo.Column(2)) > 0 Then Me.Postcode = cbo.Column(2) End If Else Me.Postcode = Null End If End If Set cbo = Nothing
The combo in Use As the user types the first two characters, the drop-down list is empty. At the third character, the list fills with just the entries beginning with those three characters. At the fourth character, Access completes the first matching name (assuming the combo's AutoExpand is on). Once enough characters are typed to identify the suburb, the user tabs to the next field. As they leave the combo, State and Postcode are assigned. The time taken to load the combo between keystrokes is minimal. This occurs once only for each entry, unless the user backspaces through the first three characters again. If your list still contains too many records, you can reduce them by another order of magnitude by changing the value of constant conSuburbMin from 3 to 4, i.e.: Const conSuburbMin = 4
ADDING VALUES TO LOOKUP TABLES Combo boxes give quick and accurate data entry: accurate: you select an item from the list; quick: a couple of keystrokes is often enough to select an item. But how do you manage the items in the list? Access gives several options. Option 1: Not In List event When you enter something that is not in the combo's list, its Not In List event fires. Use this event to add the new item to the RowSource table. This solution is best for simple lists where there is only one field, such as choosing a category. You must set the combo's Limit To List property to Yes, or the Limit To List event won't fire. In the Northwind sample database, the Products form has a CategoryID combo. This example shows how to add a new category by typing one that does not already exist in the combo:
Private Sub CategoryID_NotInList(NewData As String, Response As Integer) Dim strTmp As String
'Get confirmation that this is not just a spelling error. strTmp = "Add '" & NewData & "' as a new product category?" If MsgBox(strTmp, vbYesNo + vbDefaultButton2 + vbQuestion, "Not in list") = vbYes Then
'Append the NewData as a record in the Categories table. strTmp = "INSERT INTO Categories ( CategoryName ) " & _ "SELECT """ & NewData & """ AS CategoryName;" DBEngine(0)(0).Execute strTmp, dbFailOnError
'Notify Access about the new record, so it requeries the combo. Response = acDataErrAdded End If End Sub
Option 2: Pop up a form If the combo's source table has several fields, you need a form. Access 2007 gave combos a new property to make this very easy. Using the List Items Edit Form property (Access 2007 and later) Just set this property to the name of the form that should be used to manage the items in the combo's list. This approach is very simple, and requires no code. The example below is for a CustomerID combo on an order form. When filling out an order, you can right-click the combo to add a new customer. The combo properties (design view) The right-click shortcut menu (to add a new customer)
Limitations: Previous versions of Access cannot do this. The form is opened modally (dialog mode), so you cannot browse elsewhere to decide what to add. The form does not open to the record you have in the combo. You have to move to a new record, or find the one you want to edit. (You can set the form's Data Entry property to Yes, but this does not make it easy for a user to figure out how to edit or delete an item.) Using another event to open a form To avoid these limitations, you could choose another event to pop up the form to edit the list. Perhaps the combo's DblClick event, a custom shortcut menu, or the click of a command button beside the combo. This approach does require some programming. There are several issues to solve here, since the edit form may already be open. Add code like this to the combo's event:
Private Sub CustomerID_DblClick(Cancel As Integer) Dim rs As DAO.Recordset Dim strWhere As String Const strcTargetForm = "Customers"
'Set up to search for the current customer. If Not IsNull(Me.CustomerID) Then strWhere = "CustomerID = """ & Me.CustomerID & """" End If
'Open the editing form. If Not CurrentProject.AllForms(strcTargetForm).IsLoaded Then DoCmd.OpenForm strcTargetForm End If With Forms(strcTargetForm)
'Save any edits in progress, and make it the active form. If .Dirty Then .Dirty = False .SetFocus If strWhere <> vbNullString Then 'Find the record matching the combo. Set rs = .RecordsetClone rs.FindFirst strWhere If Not rs.NoMatch Then .Bookmark = rs.Bookmark End If Else 'Combo was blank, so go to new record. RunCommand acCmdRecordsGoToNew End If End With Set rs = Nothing End Sub
Then, in the pop up form's module, requery the combo:
Private Sub Form_AfterUpdate() On Error GoTo Err_Handler 'Purpose: Requery the combo that may have called this in its DblClick Dim cbo As ComboBox Dim iErrCount As Integer Const strcCallingForm = "Orders"
If CurrentProject.AllForms(strcCallingForm).IsLoaded Then Set cbo = Forms(strcCallingForm)!CustomerID cbo.Requery End If
Exit_Handler: Exit Sub
Err_Handler: 'Undo the combo if it has a partially entered value. If (Err.Number = 2118) And (iErrCount < 3) And Not (cbo Is Nothing) Then cbo.Undo Resume End If MsgBox "Error " & Err.Number & ": " & Err.Description Resume Exit_Handler End Sub
Private Sub Form_BeforeDelConfirm(Cancel As Integer, Response As Integer) If Response = acDeleteOK Then Call Form_AfterUpdate End If End Sub
Option 3: Combos for entering free-form text Set the combo's Limit To List property to No, and it lets you enter values values that are not in the list. This approach is suitable for free-form text fields where the value might be similar to other records, but could also be quite different. For example, a comments field where comments might be similar to another record, but could be completely different. The auto-expand makes it quick to enter similar comments, but it gives no accuracy. It is unnormalized, and completely unsuitable if you might need to count or group by the lookup category. If the combo's bound column is not the display column, you cannot set Limit To List to No. To populate the combo's list, include DISTINCT in its Row Source, like this: SELECT DISTINCT Comments FROM Table1 WHERE (Comments Is Not Null) ORDER BY Comments; Anything you type in the combo is saved in the Comments field. The list automatically shows the current items next time you open the form. This approach is useful in only very limited scenarios.
Option 4: Add items to a Value List In a word, DON'T! No serious developer should let users add items to value lists, despite Access 2007 introducing a raft of new properties for this purpose. If you set a combo's Row Source Type to Value List, you can enter the list of items (separated by semicolons) in the Row Source. You might do this for a very limited range of choices that will never change (e.g. "Male"; "Female.") But letting the user add items to the list almost guarantees you will end up with bad data. Managing the Value List in the Form Access 2007 introduced the Allow Value List Edits property. If you set this to Yes, you can right-click the combo and choose Edit List Items in the shortcut menu. Access opens a dialog where you can add items, remove items, or edit the items in the list. Let's ignore the fact that this doesn't work at all if the combo's Column Count is 2 or more. The real problem is that there is no relational integrity: You can remove items that are actually being used in other records. You can correct a misspelled item, but the records that already have the misspelled item are not corrected. You can add items to your form, but in a split database, other users don't get these items. Consequently, other users add items with other names to their forms, even where they should be the same item. If that's not bad enough, it gets worse when you close the form. Access asks: Do you want to save the changes to the design of the form? Regardless of how you answer that question, things go bad: If you answer No after using one of the new items, you now have items in the data that don't match the list. If you answer Yes in an unsplit database, you introduce strange errors as multiple users attempt to modify objects that could be in use by other people. If you answer Yes in an split database, the list of items in one front end no longer matches the lists in the others. Your changes don't last anyway: they are lost when the front end is updated. There is no safe, reliable way for users to add items to the Value List in the form without messing up the integrity of the data. Managing the Value List in the Table What about storing the value list in the table instead of the form? Access 2007 and later can do that, but again it's unusable. Don't do it! Some developers hate the idea of a combo in a table anyway. Particularly if the Bound Column is not the display value, it confuses people by masking what is really stored there, not to mention the issues with the wizard that creates this. For details, see The Evils of Lookup Fields in Tables. But lets ignore this wisdom, and explore what happens of you store the value list in the table. Select the field in table design, and in the lower pane (on the Lookup tab), set the properties like this: Display Control Combo Box Row Source Type Value List Allow Value List Edits Yes Row Source "dog"; "cat"; "fish" Now create a form using this table, with a combo for this field. Set the combo's Inherit Value List property to Yes. Now Access ignores the Row Source list in the form, and uses the list from the table instead. If you edit the list (adding, deleting, or modifying items), Access stores the changes in the properties of the field in the table. Does this solve the problems associated with keeping the list in the form? No, it does not. If the database is split (so the table is attached), the changed Value List is updated in the linked table in the front end only. It is not written to the real table in the back end. Consequently, the changed Value List is not propagated to other users. We still have the same problem where each user is adding their own separate items to the list. And we have the same problem where the user's changes are lost when the front end is updated. (Just for good measure, the Row Source of the field in the linked table does not display correctly after it has been updated in this way, though the property is set if you examine it programmatically.) At this point, it seems pointless to continue testing. One can also imagine multi-user issues with people overwriting each others' entries as they edit the data if the database is not split. There is no safe, reliable way for users to add items to the Value List without messing up the integrity of the data. Managing the Value List for Multi-Valued fields Multi-valued fields (MVFs - introduced in Access 2007), suffer from the same issues if you let users edit their value list. The MVFs have one more property that messes things up even further: Show Only Row Source Values. If you set this property to Yes, and allow users to modify the value list, itsuppresses the display of items that are no longer in the list. A user can now remove an item from the list even though 500 records in your database are using it. You will no longer see the value in any of the records where it is stored. At this point, not only have you messed up the integrity of the data, you have also messed up the display of the data, so no end user has any idea what is really stored in the database. (It can only be determined programmatically.)
USE A MULTI-SELECT LIST BOX TO FILTER A REPORT This article explains how to use a multi-select list box to select several items at once, and open a report limited to those items. With a normal list box or text box, you can limit your report merely by placing a reference to the control in the Criteria row of its query, e.g. [Forms].[MyForm].[MyControl]. You cannot do that with a multi-select list box. Instead, loop through the ItemsSelected collection of the list box, generating a string to use with the IN operator in the WHERE clause of your SQL statement. This example uses the Products by Category report in the Northwind sample database. The steps Open the Northwind database. Open the query named Products by Category in design view, and add Categories.CategoryID to the grid. Save, and close. Create a new form, not bound to any table or query. Add a list box from the Toolbox. (View menu if you see no toolbox.) Set these properties for the list box: Name lstCategory Multi Select Simple Row Source Type Table/Query Row Source SELECT Categories.CategoryID, Categories.CategoryName FROM Categories ORDER BY Categories.CategoryName; Column Count 2 Column Widths 0 Add a command button, with these properties: Name cmdPreview Caption Preview On Click [Event Procedure] Click the Build button (...) beside the On Click property. Access opens the code window. Paste the code below into the event procedure. Access 2002 and later only: Open the Products by Category report in design view. Add a text box to the Report Header section, and set its Control Source property to: =[Report].[OpenArgs] The code builds a description of the filter, and passes it with OpenArgs. See note 4 for earlier versions. The code
Private Sub cmdPreview_Click() On Error GoTo Err_Handler 'Purpose: Open the report filtered to the items selected in the list box. 'Author: Allen J Browne, 2004. http://allenbrowne.com Dim varItem As Variant 'Selected items Dim strWhere As String 'String to use as WhereCondition Dim strDescrip As String 'Description of WhereCondition Dim lngLen As Long 'Length of string Dim strDelim As String 'Delimiter for this field type. Dim strDoc As String 'Name of report to open.
'strDelim = """" 'Delimiter appropriate to field type. See note 1. strDoc = "Products by Category"
'Loop through the ItemsSelected in the list box. With Me.lstCategory For Each varItem In .ItemsSelected If Not IsNull(varItem) Then 'Build up the filter from the bound column (hidden). strWhere = strWhere & strDelim & .ItemData(varItem) & strDelim & "," 'Build up the description from the text in the visible column. See note 2. strDescrip = strDescrip & """" & .Column(1, varItem) & """, " End If Next End With
'Remove trailing comma. Add field name, IN operator, and brackets. lngLen = Len(strWhere) - 1 If lngLen > 0 Then strWhere = "[CategoryID] IN (" & Left$(strWhere, lngLen) & ")" lngLen = Len(strDescrip) - 2 If lngLen > 0 Then strDescrip = "Categories: " & Left$(strDescrip, lngLen) End If End If
'Report will not filter if open, so close it. For Access 97, see note 3. If CurrentProject.AllReports(strDoc).IsLoaded Then DoCmd.Close acReport, strDoc End If
'Omit the last argument for Access 2000 and earlier. See note 4. DoCmd.OpenReport strDoc, acViewPreview, WhereCondition:=strWhere, OpenArgs:=strDescrip
Exit_Handler: Exit Sub
Err_Handler: If Err.Number <> 2501 Then 'Ignore "Report cancelled" error. MsgBox "Error " & Err.Number & " - " & Err.Description, , "cmdPreview_Click" End If Resume Exit_Handler End Sub
PRINT A QUANTITY OF A LABEL Need several labels for the same record? This tip works for a fixed number of labels (e.g. a whole sheet for each client), or a variable number (where the quantity is in a field). An unreliable approach A common suggestion is to toggle NextRecord (a runtime property of the report) in the Format event of the Detail section. This approach works if the user previews/prints all pages of the report. It fails if only some pages are previewed/printed: the events for the intervening pages do not fire, so the results are inconsistent. This approach also fails in the new Report view in Access 2007 and later, since the events of the sections do not fire in this view. A Better Solution A simpler and code-free solution uses a query with a record for each label. To do this, you need a table containing a record from 1 to the largest number of labels you could ever need for any one record. Create a new table, containing just one field named CountID, of type Number (Long Integer). Mark the field as the primary key (toolbar icon). Save the table as tblCount. Enter the records into this table manually, or use the function below to enter 1000 records instantly. Create a query that contains both this table and the table containing your data. If you see any line joining the two tables, delete it. It is the lack of a join that gives you a record for each combination. This is known as a Cartesian Product. Drag tblCount.CountID into the query's output grid. Use the Criteria row beneath this field to specify the number of labels. For example, if your table has a field namedQuantity, enter: <= [Quantity] or if you always want 16 labels, enter: <= 16 Include the other fields you want, and save the query. Use it as the RecordSource for your label report. Optional: To print "1 of 5" on the label, add a text box to the report, with this in its ControlSource: =[CountID] & " of " & [Quantity] Ensure the Name of this text box is different from your field names (e.g. it can't be named "CountID" or "Quantity"). To ensure the labels print in the correct order, include CountID in the report's Sorting And Grouping dialog. That's it.
Here's the function that will enter 1000 records in the counter table. Paste it into a module. Then press Ctrl+G to open the Immediate window, and enter: ? MakeData() Function MakeData() 'Purpose: Create the records for a counter table. Dim db As Database 'Current database. Dim lng As Long 'Loop controller. Dim rs As DAO.Recordset 'Table to append to. Const conMaxRecords As Long = 1000 'Number of records you want.
Set db = DBEngine(0)(0) Set rs = db.OpenRecordset("tblCount", dbOpenDynaset, dbAppendOnly) With rs For lng = 1 To conMaxRecords .AddNew !CountID = lng .Update Next End With rs.Close Set rs = Nothing Set db = Nothing MakeData = "Records created." End Function
HAS THE RECORD BEEN PRINTED?
This question is usually asked as, "How can I mark a record as printed? Not just previewed - when it actually goes to the printer?" The question has some thorny aspects. Firstly, it is tricky to tell printing from previewing. Worse, printers run out of ink/toner, or the paper jams and someone turns it off before the job really prints. It needs more than just a yes/no field to mark the record as printed or not. A better solution is to mark the records as part of a print run before they are sent to the printer. You can then send the batch again if something goes wrong. You have a record of when the record was printed, and you can can reprint a batch at any time. So, instead of a yes/no field indicating if the record has printed, you use a Number field and store the batch number. The number is blank until the record has been printed. Then it contains the number of the print batch. If something goes wrong, you send the print run again. Download the sample database (27kb zipped.) Requires Access 2000 or later. Assign the number first, then print the batch The database has a table where you enter new members (tblMember), and a table that tracks the print runs (tblBatch.) When you enter a new member, you leave the BatchID blank. When you are ready to print the new members, open frmBatch (shown above.) Click the Create New Batch button. It creates a new entry in tblBatch, and assigns the new batch number to all the members that have not been printed (i.e. BatchID is null.) Then clickPrint Selected Batch to print those records. It prints the batch by filtering the report. Not only do you know if a record was printed: you kwow when it was printed. If something goes wrong with the printer, you send the batch again. You can even undo the batch, and recreate it if necessary. Taking it further This section explains a couple of ways to to extend the database beyond the example. Track each time a record is printed In some databases, you may want to track each time a record is printed. Since one record can be printed many times, you need a related table to do this. The table has two fields: BatchID - relates to tblBatch.BatchID MemberID - relates to tblMember.MemberID Now if batch 7 should contain 12 members, you add 12 records to this table. Then print the matching records with a query that filters just the one batch. You now have a complete history of each time a record was printed. (The sample database shows this table, but does not demonstrate how to use it.) Simplifying the Undo The Undo button in sample database sets the BatchID to Null for all records in the batch, and then deletes the batch number. The first of those two steps could be avoided if you use acascade-to-null relation.
CODE ACCOMPANYING ARTICLE: HAS THE RECORD BEEN PRINTED? The article Has the record been printed? shows how to create print runs (batches) that track when new records are printed. The code below lists the code behind the 3 buttons. Download the sample database if you prefer (27 kb zipped, Access 2000 and later.) Option Compare Database Option Explicit
Private Sub cmdCreateBatch_Click() 'On Error GoTo Err_Handler Dim db As DAO.Database Dim rs As DAO.Recordset Dim strSql As String Dim lngBatchID As Long Dim lngKt As Long
'Create the new batch, and get the number. Set db = CurrentDb() Set rs = db.OpenRecordset("tblBatch", dbOpenDynaset, dbAppendOnly) rs.AddNew rs!BatchDateTime = Now() lngBatchID = rs!BatchID rs.Update rs.Close
'Give this batch number to all members who have not been printed. strSql = "UPDATE tblMember SET BatchID = " & lngBatchID & " WHERE BatchID Is Null;" db.Execute strSql, dbFailOnError lngKt = db.RecordsAffected
Exit_Handler: Set rs = Nothing Set db = Nothing Exit Sub
Err_Handler: MsgBox "Error " & Err.Number & ": " & Err.Description, vbExclamation, "cmdCreateBatch_Click()" Resume Exit_Handler End Sub
Private Sub cmdPrintBatch_Click() 'On Error GoTo Err_Handler Dim strWhere As String Const strcDoc = "rptMemberList"
If IsNull(Me.lstBatch) Then MsgBox "Select a batch to print." Else 'Close the report if it's already open (so the filtering is right.) If CurrentProject.AllReports(strcDoc).IsLoaded Then DoCmd.Close acReport, strcDoc End If 'Open it filtered to the batch in the list box. strWhere = "BatchID = " & Me.lstBatch DoCmd.OpenReport strcDoc, acViewPreview, , strWhere End If
Exit_Handler: Exit Sub
Err_Handler: MsgBox "Error " & Err.Number & ": " & Err.Description, vbExclamation, ".cmdPrintBatch_Click" Resume Exit_Handler End Sub
Private Sub cmdUndoBatch_Click() 'On Error GoTo Err_Handler Dim db As DAO.Database Dim strSql As String Dim varBatchID As Variant Dim lngKt As Long
'Get the highest batch number. varBatchID = DMax("BatchID", "tblBatch") If IsNull(varBatchID) Then MsgBox "No batches found." Else 'Clear all the members of the batch. Set db = CurrentDb() strSql = "UPDATE tblMember SET BatchID = Null WHERE BatchID = " & varBatchID & ";" db.Execute strSql, dbFailOnError 'Delete the batch. strSql = "DELETE FROM tblBatch WHERE BatchID = " & varBatchID & ";" db.Execute strSql, dbFailOnError lngKt = db.RecordsAffected
'Show the response. Me.lstBatch.Requery MsgBox "Batch " & varBatchID & " deleted. " & lngKt & " member(s) marked as not printed." End If
Exit_Handler: Set db = Nothing Exit Sub
Err_Handler: MsgBox "Error " & Err.Number & ": " & Err.Description, vbExclamation, ".cmdUndoBatch_Click" Resume Exit_Handler End Sub
CASCADE TO NULL RELATIONS Abstract: Highlights a little-known feature in Access, where related records can be automatically set to Null rather than deleted when the primary record is deleted. Introduction Have you ever set up a table with a foreign key that is null until some batch operation occurs? A not-for- profit organisation might send thank you letters at the end of each period to acknowledge donors. The Donation table therefore has a LetterID field that is Null until a batch routine is run to create a letter for each donor, and assign this LetterID to each of the records in the Donation table that are acknowledged in the letter. So the user can undo the batch, you end up writing code to execute an Update query on the Donation table to change LetterID back to Null for all letters in the batch, deletes the Letters from their table, and deletes the BatchID from the Batch table. Well, thats the way you used to code a batch undo! There is now a way to get JET (the data engine in Access) to automatically set the LetterIDback to Null when the letters are deleted, at the engine level, without a single line of code. Cascade-to-Null was introduced six years ago, but has remained below the radar for most developers. This article explains how to create this kind of cascading relation, with a simple example to use with Northwind, and a sample database (13KB zipped) illustrating both DAO and ADOX approaches. But first, a quick review of Nulls in foreign keys. Referential Integrity and Nulls When you create a relationship in Access, you almost always check the box for Referential Integrity (RI). This little check box blocks invalid entries in the related table, and opens the door for cascading updates and deletes. What that check box does not do is prevent Nulls in the foreign key. In most cases, you must block this possibility by setting the Required property of the foreign key field in its table. But there are cases where a Null foreign key makes good sense. Batch operations like the receipt letters above are common. Even for something as simple as items in a category, you might want to allow items that have no category, so the CategoryID foreign key can be Null. What is Cascade-to-Null? We have mentioned three ways the database engine can enforce referential integrity: Normal: Blocks the deletion or alteration of entries in the primary table if they are used in the related table. Cascading Update: Automatically updates all matching entries in the related table when you change an entry in the primary table. Cascading Delete: Automatically deletes all matching entries in the related table when you delete an entry in the primary table. There is a fourth way the database could maintain RI: when a record is deleted from the primary table, it could set the foreign key field of all related records to Null. Benefits of Cascade-to-Null: Related records are not lost! Integrity is maintained. (There are no records with an invalid foreign key.) The Null value in the foreign key perfectly represents the concept of unknown or unspecified. Imagine a user created a goofy category in Northwind, and assigned it to several products. You need to delete the category, but without losing the products. With this kind of relation between Categories and Products, you can just delete the category, and all affected products become uncategorised. No code. No update queries. No testing: the engine takes care of it for you. This is cascade-to-null: when the primary record is deleted, the foreign key of the matching records is set to Null automatically. Creating a Cascade-to-Null relation How has a feature this great remained unknown for most developers? Microsoft gave us the feature in Access 2000, but they never updated the interface. There is no Cascade-to-Null check box in the Edit Relationships window. You can only create this kind of relation programmatically. As the example below demonstrates, the code is very simple. These steps work with Northwind to replace the relation between Products and Categories with a cascade-to-null. Open Northwind.mdb in Access 2000 or later. Open the Relationships window. In Access 2000 - 2003, choose Relationships from the Tools menu. In Access 2007 and later, click Relationships on the Database Tools tab of the ribbon. Delete the existing relation between Products and Categories: right-click the line joining them, and choose Delete. Close the Relationships window. Create a new module. In Access 2000 - 2003, select the Modules tab of the Database window, and click New. In Access 2007 and later, choose Module (right-most icon) on the Create ribbon. Paste the code below into the new module. Run the code: open the Immediate Window (Ctrl+G), and enter: Call MakeRel() The response will be the value for the Cascade-to-Null relation attribute: 8192 Here's the code:
'Define the bit value for the relation Attributes. Public Const dbRelationCascadeNull As Long = &H2000 Public Function MakeRel() 'Purpose: Create a Cascade-to-Null relation using DAO. Dim db As DAO.Database Dim rel As DAO.Relation Dim fld As DAO.Field
Set db = CurrentDb() 'Arguments for CreateRelation(): any unique name, primary table, related table, attributes. Set rel = db.CreateRelation("CategoriesProducts", "Categories", "Products", dbRelationCascadeNull) Set fld = rel.CreateField("CategoryID") 'The field from the primary table. fld.ForeignName = "CategoryID" 'Matching field from the related table. rel.Fields.Append fld 'Add the field to the relation's Fields collection. db.Relations.Append rel 'Add the relation to the database.
'Report and clean up. Debug.Print rel.Attributes Set db = Nothing End Function
To test it, open the Categories table and enter a new category, with a name such as "Goofy Food", and close. Open the Products table, and change the Category for a couple of products to this new category, and close. Then open the Categories table again, and delete the Goofy Food category. You will see this dialog:
Choose Yes. Open the Products table, and you see that the products that you previously placed in the Goofy Food category are now uncategorised. Deleting the Category caused them to cascade to Null. (Note that Access does not have a dialog for Cascade-to-Null, so it uses the Cascade-Delete message.) Maintaining Cascade-to-Null relations Since the engine is maintaining the integrity of your data, this kind of relation means there are fewer update queries to execute. This in turn means less code to write, since the engine takes care of this for you. But what if someone else needs to rebuild the database at some stage? Since the interface cannot show them that cascade-to-null relations are in force, they may recreate the tables and have no idea that your application relies on this type of cascade. You need a way to document this, and ideally it should be visible in the Relationships window. Create a table purely for documentation. The table will never hold records. To ensure it shows in the Relationships window, create a relation to other tables, so it is not only saved in the Relationships view now, but shows up when the Show All Relationships button is clicked. The field names can be anything, but since the goal is to catch attention, you might create a sentence using odd names reserved words: Field Name Data Type Description * * * WARNING * * * Text Informational only: no data. Cascade Text to Text Null Text Relations Text Exist Text On Text Products Text And Text Categories Text Id Number Primary key Then open the Relationships window (Tools menu), and add the table. Drag the CategoryID field from the Categories table to the Id field in your new table, and create the relationship.
A Real World Example Cascade-to-Null is useful beyond the simple "category" example above. In fact, it is worth considering in any relation where the foreign key is not required. For example, you may have sales dockets that need to be collated into an invoice for each client at the end of the month. Since the sales dockets will become line items of an invoice, they have an InvoiceID foreign key that is null until the invoices are generated. The new invoices will be assigned a batch number, so the user can undo the entire batch if something goes wrong, fix the data, and run the batching process again. Using a cascade-to-null relation between the invoice and the original docket record means that if you delete an invoice (or the whole batch), Access automatically updates all the sales items back to null. Next time the batch process is run, your code recognises that the sales records are not part of an account, and so they pick up those records automatically. You probably have a cascading delete between your Batch table and Invoice table. So, you can now delete a single batch record: the related invoices are deleted, and the originalsales dockets are cascaded to Null. No code. No chance of making a mistake: it is all maintained by JET.
LIST BOX OF AVAILABLE REPORTS In some applications you may wish to offer the user the choice to print any saved report. It would be handy to have Access fill the names of all reports into a list box for the user to select and print. Here are two solutions. Method 1: Query the Hidden System Table (undocumented) This very simple approach queries the hidden system table Access itself uses to keep track of your reports. The danger of an undocumented approach is that there is no guarantee it will work with future versions. Since Microsoft has announced that the current version (4) is the last version of JET they will release, that problem seems rather academic. So, all you need do is copy this SQL statement and paste it into the RowSource of your list box: SELECT [Name] FROM MsysObjects WHERE (([Type] = -32764) AND ([Name] Not Like "~*") AND ([Name] Not Like "MSys*")) ORDER BY [Name]; When an object is deleted but before the mdb is compacted, it is marked for deletion and assigned a name starting with "~". The query skips those names. Should you need to query other object types, the values for MSysObjects.Type are: Table 1 Query 5 Form -32768 Report -32764 Module -32761
Method 2: Callback Function If you don't like the undocumented approach, or would like to experiment with call back functions, here is the other alternative. Create a form with: a list box named lstReports, with a label Reports; a check box named chkPreview, with a label Preview; a command button named cmdOpenReport, with caption Open Report. Set the command button's OnClick property to [Event Procedure]. Click the "..." button beside this property to open the code window, and enter the following:
Private Sub cmdOpenReport_Click() ' Purpose: Opens the report selected in the list box. On Error GoTo cmdOpenReport_ClickErr If Not IsNull(Me.lstReports) Then DoCmd.OpenReport Me.lstReports, IIf(Me.chkPreview.Value, acViewPreview, acViewNormal) End If Exit Sub
cmdOpenReport_ClickErr: Select Case Err.Number Case 2501 ' Cancelled by user, or by NoData event. MsgBox "Report cancelled, or no matching data.", vbInformation, "Information" Case Else MsgBox "Error " & Err & ": " & Error$, vbInformation, "cmdOpenReport_Click()" End Select Resume Next End Sub
Set the list box's RowSourceType property to EnumReports. Leave the RowSource property blank. Create a new module and copy the function below into this module:
Function EnumReports(fld As Control, id As Variant, row As Variant, col As Variant, code As Variant) As Variant ' Purpose: Supplies the name of all saved reports to a list box. ' Usage: Set the list box's RowSourceType property to:? EnumReports ' leaving its RowSource property blank. ' Notes: All arguments are provided to the function automatically. ' Author: Allen Browne allen@allenbrowne.com Feb.'97.
Dim db As Database, dox As Documents, i As Integer Static sRptName(255) As String ' Array to store report names. Static iRptCount As Integer ' Number of saved reports.
' Respond to the supplied value of "code". Select Case code Case acLBInitialize ' Called once when form opens. Set db = CurrentDb() Set dox = db.Containers!Reports.Documents iRptCount = dox.Count ' Remember number of reports. For i = 0 To iRptCount - 1 sRptName(i) = dox(i).Name ' Load report names into array. Next EnumReports = True Case acLBOpen EnumReports = Timer ' Return a unique identifier. Case acLBGetRowCount ' Number of rows EnumReports = iRptCount Case acLBGetColumnCount ' 1 column EnumReports = 1 Case acLBGetColumnWidth ' 2 inches EnumReports = 2 * 1440 Case acLBGetValue ' The report name from the array. EnumReports = sRptName(row) Case acLBEnd Erase sRptName ' Deallocate array. iRptCount = 0 End Select End Function
How the callback function works The RowSourceType property of a list box can be used to fill the box programmatically. The five arguments for the function are provided automatically: Access calls the function repeatedly using these arguments to indicate what information it is expecting. During the initialization stage, this function uses DAO (Data Access Objects) to retrieve and store the names of all reports into an static array. (Note: it is necessary to use theContainers!Reports.Documents collection, as the Reports object refers only open reports.) The command button simply executes an OpenReport action. If the check box is checked, the report is opened in Preview mode, else it is printed directly.
FORMAT CHECK BOXES IN REPORTS Consider using a text box in place of a check box on reports. You can then display crosses or check marks, boxed or unboxed, any size, any color, with background colors, and even use conditional formatting. The text box's Format property lets you specify a different format for negative values. Since, Access uses -1 for True, and 0 for False, the Format property lets you display any characters you want. For this example, we use the check mark characters from the Wingdings font, since all Windows computers have this font installed. The steps Open the report in design view. If you already have a check box on your report, delete it. Add a text box for your Yes/No field. Set these properties: Control Source: Name of your yes/no field here. Font Name: WingDings Width: 0.18 in Type these characters into the Format property of the text box: Hold down the Alt key, and type 0168 on the numeric keypad (the character for False), semicolon (the separator between False and True formats), backslash (indicating the next character is a literal), Hold down the Alt key, and type 0254 on the numeric keypad (the character for True), You can now increase the Font Size, set the Fore Color or Back Color, and so on. The characters Select the characters from this list: Character Keypad number Description
Alt+0254 Checked box
Alt+0253 Crossed box
Alt+0252 Check mark (unboxed)
Alt+0251 Cross mark (unboxed)
Alt+0168 Unchecked box To leave the text box blank for unchecked, omit the first character in the Format property, i.e. nothing before the semi-colon. You can find other characters with the Character Map applet that comes with all versions of Windows. There are many other uses for these symbols, e.g. as graphics on your command buttons.
SORTING REPORT RECORDS AT RUNTIME Access reports do their own sorting based on the sort fields you specify in the Sorting and Grouping dialog of the report. The recordsource Order By clause is ignored. Microsoft has a knowledgebase article that explains a technique for using setting the OrderBy property of a report by opening the report in design view (Article ID: Q146310). I have always preferred to programmatically set the group levels of the report, with code like this in the Open event of the report:
Select Case Forms!frmChooseSort!grpSort Case 1 'Name Me.GroupLevel(0).ControlSource = "LastName" Me.GroupLevel(1).ControlSource = "FirstName" Me.GroupLevel(2).ControlSource = "Company" Case 2 'Company Me.GroupLevel(0).ControlSource = "Company" Me.GroupLevel(1).ControlSource = "LastName" Me.GroupLevel(2).ControlSource = "FirstName" End Select
To make this work, you just need to make sure that you have set up the right number of grouping levels in the report's grouping and sorting dialog.
Print a page with 3 evenly spaced mailing slips The Label Wizard matches most mailing labels, but what if you need to divide a page into three slips with address panels in exactly the same place? Most printers require a top and bottom margin, so just dividing the remaining space by three does not place the address panels correctly. The solution is to add a Group Footer section to act as a spacer between the slips. Set the height of this spacer to the top margin plus the bottom margin. If you can then suppress this footer underneath the third slip, you end up with a page that prints like this: Letter A4 0.5" Top margin 0.5" 2.666" Detail (1st mailing slip) 2.888" 1" --------Group Footer (spacer)-------- 1" 2.666" Detail (2nd mailing slip) 2.888" 1" --------Group Footer (spacer)-------- 1" 2.666" Detail (3rd mailing slip)
(Group Footer suppressed) 2.888" 0.5" Bottom margin 0.5" Steps to create the report Create a report based on the table or query that contains the data. In report design view, if you see Page Header and Page Footer sections, remove them by clicking Page Header/Footer on the View menu in Access 95 - 2003. In Access 2007, on the Report Tools ribbon click Report Header/Footer on the Show/Hide group (right-most icon.) Set the Top and Bottom margins to 0.5". In Access 95 - 2003, choose Page Setup from the File menu. In Access 2007,on the Report Tools ribbon, click the Extend arrow at the very bottom right corner of the Page Setup group. If your printer requires the bottom margin to be greater, try 0.3" for the top margin and 0.7" for the bottom. Use the diagram above to recalculate your heights if you need more than 1" in total: the sum of all section heights must be 11" or less for Letter, 11.666" or less for A4. Right-click the grey bar called Detail, and choose Properties. On the Format tab of the Properties box, set the Height of the Detail section to 2.666" for Letter paper, 2.888" for A4. Add a text box to the Detail section, to use as a counter. Give it these properties: Control Source: =1 Running Sum: Over All Name: txtCount Visible: No Create a Group Footer on your primary key field: In Access 1 - 2003, open the Sorting and Grouping dialog (View menu). Select your primary key field. In the lower pane of the dialog, set Group Footer to Yes. In Access 2007, on the Design ribbon, click Grouping in the Grouping and Totals group. Select the primary key field. Click More. Click Without a Header. Click With a Footer. In the Properties box, set the Height of this group footer section to 1 inch (i.e. top margin plus bottom margin). (Optional.) Add a dotted line to the middle of this section to indicate where to cut the page. To suppress this group footer below the third label on the page, set the On Format property of this section to [Event Procedure]. Click the Build button (...) beside this. Add this line to the code window, so the event procedure looks like this:
Private Sub GroupFooter0_Format(Cancel As Integer, FormatCount As Integer) Me.GroupFooter0.Visible = (((Me.txtCount - 1) Mod 3) <> 2) End Sub Explanation of the code The hidden text box accumulates 1 for each record. When the group footer is formatted, it examines the value of the text box less 1. This yields 0 for the first record, 1 for the next, 2 for the next, 3 for the next, and so on. The Mod operator gives for the remainder after division. Results will be 0, 1, 2, 0, 1, 2, 0, ... Every third record has the value 2, and that is the bottom one on the page. So, if the result is different from 2, we set the group section's Visible property to True. If is is not different from 2, the Visible property is False. The technique can be easily adapted for other page sizes and numbers of slips.
REPORTS: PAGE TOTALS Each section of a report has a Format event, where you can alter properties such as Visible, Color, Size, etc in a manner similar to the Current in a form. (See Runtime Properties: Formsfor details.) For example to force a page break when certain conditions are met, include a PageBreak control in the Detail Section and toggle its Visible property. In addition, the Printevent can be used to perform tasks like adding the current record to a running total. You have an Amount field, and want to display the Amount total for that page in the Page Footer. Totals are not normally available in the Page Footer, but the task requires just four lines of code! In the PageHeader's Format event procedure, add: curTotal = 0 'Reset the sum to zero each new Page. In the DetailSection's Print event, add: If PrintCount = 1 Then curTotal = curTotal + Me.Amount Place an unbound control called PageTotal in the Page Footer. In the PageFooter's Format, add: Me.PageTotal = curTotal In the Code Window under Declarations enter: Option Explicit 'Optional, but recommended for every module. Dim curTotal As Currency 'Variable to sum [Amount] over a Page. That's it!
REPORTS: A BLANK LINE EVERY FIFTH RECORD In addition to the OnFormat and OnPrint events (see Reports: Page Totals for an example), Access 2 and later provide three True/False properties: MoveLayout: if False, prints on top of what was printed last; NextRecord: if False, prints the same record again; PrintSection: if False, doesn't print any data. Each is normally set to True, but the combination of the three allows fine control over what is printed when and where. For example, a report's readability might be enhanced by a blank line every five records. In the report's Declarations enter: Option Explicit Dim fBlankNext As Integer 'Flag: print next line blank? (True/False) Dim intLine As Integer 'A line counter.
Select the Page Header section, and enter this in the OnFormat event procedure: intLine = 0 'Reset line counter at top of page. fBlankNext = False 'Never print first line of page blank.
Now select the Detail Section's OnPrint, and enter this code without the line numbers:
If PrintCount = 1 Then intLine = intLine + 1 If fBlankNext Then Me.PrintSection = False Me.NextRecord = False fBlankNext = False Else Me.PrintSection = True Me.NextRecord = True fBlankNext = (intLine Mod 5 = 0) End If Need some explanation? In line 9, the statement inside the brackets evaluates to True when the line counter is an exact multiple of 5 (i.e. the remainder is zero). This True/False result is assigned to fBlankNext, so this flag becomes True every fifth record. When the next record is about to print and fBlankNext is True, lines 3~5 will execute. MoveLayout is still True, but PrintSection is False, so Access moves down a line and prints nothing. This gives a blank line, at the expense of the record that wasn't printed! By setting NextRecord to False (and resetting our fBlankNext flag), the missed record stays currentand is printed next time.
REPORTS: SNAKING COLUMN HEADERS To get a snaking column header above each column but only if the column has data in it do the following: Say you have two fields in the snaking column in the detail section of your report. ITEM and CARRYING. Create two unbound fields in the detail section and name them ITEM HEADER and CARRYING HEADER. Align them above the matching field getting just the way you want them (at the top of the detail section). Set their properties to be: Can grow: Yes Visible: Yes Height: 0.0007 in. Now move the actual fields up just underneath the now very skinny headers. In the properties for the detail section add an event procedure to the On Format. In it put the following code:
Sub Detail1_Format (Cancel As Integer, FormatCount As Integer) If Me.left <> dLastLeft Then Me![ITEM HEADER] = "Item" Me![CARRYING HEADER] = "Carrying" dLastLeft = Me.left sItem = Me![ITEM] Else If sItem = Me![ITEM] Then Me![ITEM HEADER] = "Item" Me![CARRYING HEADER] = "Carrying" Else Me![ITEM HEADER] = "" Me![CARRYING HEADER] = "" End If End If End Sub
Set up the fields dLastLeft and sItem in any module in the general section: Global dLastLeft As DoubleGlobal, sItem As String Compile and save the module. Now when you print the report if only one column of the snaking report exist you will only get one heading. But if two exist, you will get two headings. From: Allen and LeAnne Jergensen (programmers who hate to give up!!)
DUPLEX REPORTS: START GROUPS ON AN ODD PAGE To print invoices on a printer that supports double-siding, you need each one to start on an odd page. Otherwise Fred's invoice begins on the back of Betty's. The solution is to add an extra group footer, with the Force New Page property set to Yes. Then hide this section if the next group will already begin on an odd page. Download the sample database (26KB zipped, for Access 2000 and later.) (Note: In Access 2007 and later, you must use Print or Print Preview for this to work. The events don't fire in Report view or Layout view.) Using a field twice in Sorting and Grouping In the screenshot below, the OrderID field appears on two rows. In both cases, we chose Yes for Group Header and Group Footer. This gives us two headers and two footers for OrderID. The inner footer (the one nearest the Detail section) contains the totals for the order. Its Force New Page property is set to "After Section", so the next section to print will start on a new page. The outer footer also has Force New Page set to "After Section", so the next section (the header for the next order) starts on another new page. This gives us a blank page between orders. But if we are already on an odd page, we need to suppress the blank page. We do that by setting its Visible property to No in its Format event.
The code The outer OrderID Footer's Format event code looks like this:
Private Sub GroupFooter1_Format(Cancel As Integer, FormatCount As Integer) Dim bIsOdd As Boolean
bIsOdd = (Me.Page Mod -2) 'Yields 0 for even, or -1 for odd.
'Hide this Section (and its page break) if already at odd page. With Me.Section("GroupFooter1") If .Visible = bIsOdd Then .Visible = Not bIsOdd End If End With End Sub
The Mod operator gives the remainder after division. Mod -2 yields 0 for even pages, or -1 for odd pages. Since 0 is False, and -1 is True, we set the section's Visible property to the opposite. So, the section (and its page break) is printed if the page is even, but suppressed if we are already on an odd page. The code toggles the Visible property only if needed, since setting a property is slower than reading it.
LOOKUP A VALUE IN A RANGE There is a common approach to bracketed tables and lookups. It goes something like this: BracketLow BracketHigh Rate 0.00 9.99 0.05 10.00 19.99 0.10 20.00 49.99 0.12 50.00 99999999.00 0.13 With this, use a query: SELECT Rate FROM Bracket WHERE [Enter Bracket:] BETWEEN BracketLow and BracketHigh I would prefer not to use this solution. As soon as you give users the ability to make mistakes, you have created problems. If users are allowed to create brackets with both their beginning and ending points, they will almost certainly create brackets that overlap or have gaps. The above table actually has gaps, which will become apparent if the value sought is 9.993. No rows would be returned by the query! Instead, putting only one endpoint in each row of the Rate table is sufficient. While the query work is indeed not as simple to write, it will perform well enough, as the number of rows in the Rate table would almost certainly be few. Indeed, the index for the table would only be on this single value anyway, so that's the way Access will find the row(s) necessary. There is a principle in database construction not to store derivable values. This principle could be interpreted to extend to this subject. You can derive the missing value, either upper or lower, of any bracket, as it is the value in either the previous or subsequent row's value for lower or upper (respectively) when ordered by that column. The principle of not storing derivable values has exactly the same purpose in this case as in simpler cases, where the derivation is just between columns of the current row. That principle is that, when the derivable value is stored but not equal to what would be derived, then the stored value is incorrect, and the query will malfunction on that basis. The alternative is to check the derived value against the stored value and replace it where necessary. However, this entails a query at least as complex as the one you seek to avoid in just deriving the "missing" value when needed. The query I propose generally requires a subquery to find the proper bracket, and this is slightly daunting to many who seek our advice here. I expect that, by airing my point of view here, this will stimulate those we seek to assist to consider these alternatives. So, I will illustrate my approach for their consideration. At a point in the query you build, you require a Rate for further calculation, or just to display, or both. This rate comes from a table of brackets something like this: From To Rate 0.00 9.99 5% 10.00 19.99 10% 20.00 49.99 12% 50.00 13% This last bracket represents "anything 50 or above." There are two ways to store this: with the values in the From column, or with the values from the To column. In this case, I would choose the "even, whole values" to be stored, that is, the From column. The table would look like: Minimum Rate 0.00 0.05 10.00 .10 20.00 .12 50.00 .13 In this table, I propose the Primary Key is the From value. If the "lookup" value is in a column of your query called Lookup, then the subquery returning rate would be: (SELECT Rate FROM RateTable RT1 WHERE From = (SELECT MAX(Minimum) FROM RateTable RT2 WHERE Minimum <= Lookup)) Now this is a two tier subquery (yep, it's complex, and just the thing from which you probably wanted to shield the poster). Indeed, this is a problem, because Access Jet doesn't seem to handle this well much of the time. I believe that's because the Lookup in the inner query is two nesting levels away from its source in the outer query. So now the solution becomes (sadly) even more complex. Actually, for the person requesting assistance, this may be better, however, as they can see what is happening step-by-step. The solution is to build a query that has nearly the appearance of the original table with both From and To columns, deriving the To column. However, I will provide a To column that is .01 large than my illustration. The query using Lookup will have to find the bracket where Lookup >= From AND Lookup < To (NOT less than or equal!!!) SELECT Minimum, (SELECT MIN(RT1.Minimum) FROM RateTable RT1 WHERE RT1.Minimum > RT.Minimum) AS Maximum, Rate FROM RateTable RT If you wish, you could reproduce exactly the original values by subtracting 0.01 from this Maximum. I prefer not to do this. If the query must calculate the value of Lookup, and the value is not rounded off to the nearest "penny" then it is possible that Lookup would be 9.993. In the original Rate Table, there is no value of Rate for 9.993. I know that we humans would probably choose the rate for the bracket for 0.00 to 9.99, but the computer will not do so. By deriving an upper limit as I have shown, and then restricting the comparison to be less than that value, this can be overcome, eliminating any "gaps" in the bracket structure. This is where a judicious choice of the column on which to base the actual data (the single endpoint approach) is useful, and that's why I chose the "whole values" column for this basis. There is really no substitute for remembering to round the value when Lookup is calculated in order to make this work correctly. If you want 9.993 to be in the 0.00 to 9.99 bracket yet 9.996 to be in the 10.00 to 19.99 bracket, then you must round before using Lookup.
ACTION QUERIES: SUPPRESSING DIALOGS, WHILE KNOWING RESULTS Action queries change your data: inserting, deleting, or updating records. There are multiple ways to run the query through macros or code. This article recommends Execute in preference to RunSQL. RunSQL In a macro or in code, you can use RunSQL to run an action query. Using OpenQuery also works (just like double-clicking an action query on the Query tab of the Database window), but it is a little less clear what the macro is doing. When you run an action query like this, Access pops up two dialogs:
A nuisance dialog:
Important details of results and errors:
The SetWarnings action in your macro will suppress these dialogs. Unfortunately, it suppresses both. That leaves you with no idea whether the action completed as expected, partially, or not at all. The Execute method provides a much more powerful solution if you don't mind using code instead of a macro. Execute In a module, you can run an action query like this: DBEngine(0)(0).Execute "Query1", dbFailOnError The query runs without the dialogs, so SetWarnings is not needed. If you do want to show the results, the next line is: MsgBox DBEngine(0)(0).RecordsAffected & " record(s) affected." If something goes wrong, using dbFailOnError generates an error you can trap. You can also use a transaction and rollback on error. However, Execute is not as easy to use if the action query has parameters such as [Forms].[Form1].[Text0]. If you run that query directly from the Database Window or via RunSQL, theExpression Service (ES) in Access resolves those names and the query works. The ES is not available in the Execute context, so the code gives an error about "parameters expected." It is possible to assign values to the parameters and execute the query, but it is just as easy to execute a string instead of a saved query. You end up with fewer saved queries in the Database window, and your code is more portable and reliable. It is also much more flexible: you can build the SQL string from only those boxes where the user entered a value, instead of trying handle all the possible cases. The code typically looks like this example, which resets a Yes/No field to No for all records: Function UnpickAll() Dim db As DAO.Database Dim strSql As String
strSql = "UPDATE Table1 SET IsPicked = False WHERE IsPicked = True;" Set db = DBEngine(0)(0) db.Execute strSql, dbFailOnError MsgBox db.RecordsAffected & " record(s) were unpicked." Set db = Nothing End Function
TRUNCATION OF MEMO FIELDS In Access tables, Text fields are limited to 255 characters, but Memo fields can handle 64,000 characters (about 8 pages of single-spaced text) - even more programmatically. So why do memo fields sometimes get cut off? Queries Access truncates the memo if you ask it to process the data based on the memo: aggregating, de-duplicating, formatting, and so on. Here are the most common causes, and how to avoid them: Issue Explanation Workarounds Aggregation When you depress the button, Access adds a Total row to the query design grid. If you leave Group By under your memo field, it must aggregate on the the memo, so it truncates. Choose First instead of Group By under the memo field. The aggregation is still preformed on other fields from the table, but not on the memo, so Access can return the full memo. The field name changes (e.g. FirstOfMyMemo), so change the name and Control Source of any text boxes on forms/reports. Uniqueness Since you asked the query to return only distinct values, Access must compare the memo field against all other records. The comparison causes truncation. Open the query's Properties Sheet and set Unique Values to No. (Alternatively, remove the DISTINCT key word in SQL View.) You may need to create another query that selects the distinct values without the memo, and then use it as the source for another query that retrieves the memo without de-duplicating. Format property The Format property processes the field, e.g. forcing display in upper case (>) or lower case (<). Access truncates the memo to reduce this processing. Remove anything from the Format property of: the field in table design (lower pane); the field in query design (properties sheet); the text box on your form/report. UNION query A UNION query combines values from different tables, and de-duplicates them. This means a comparing the memo field, In SQL View, replace UNION with UNION ALL. resulting in truncation. Concatenated fields When you concatenate Text or Memo fields in a query, Access treats the result as a Text field (type dbText.) If you further process this field (e.g. combining with UNION ALL), it will truncate. (See also Concatenated fields yield garbage in recordset.) The first SELECT in a UNION query defines the field type, so you can add another UNION ALL using a Memo field so Access gets the idea. For example, instead of: SELECT ID, F1 & F2 AS Result FROM Table1 UNION ALL SELECT ID, F1 & F2 AS Result FROM Table2; add a real memo field first (even though it returns no records), like this: SELECT ID, MyMemo FROM Table3 WHERE (False) UNION ALL SELECT ID, F1 & F2 AS Result FROM Table1 UNION ALL SELECT ID, F1 & F2 AS Result FROM Table2; Row Source A Memo field in the Row Source of a combo box or list box will truncate. Don't use memo fields in combos or list boxes. Note that the same issues apply to expression that are longer than 255 characters, where Access must process the expressions. Why does it truncate? Technically, there are good reasons why Access handles only the first 255 characters when it has to process memo fields. String operations are both processor and disk intensive. Performance would be slower than a sloth if Access tried to compare all the thousands of characters of your memo field against all the other thousands of characters in each of potentially millions of records. Some queries would take hours or even days to complete. If that's not enough, don't forget the comparisons are more than mere memory matching. Some data sources (e.g. Access 1 - 97 MDBs, text files) handle strings as bytes, while others (including JET 4 MDB and ACCDB files) use Unicode. Unicode needs either more disk reads or more processing to decompress, and we expect it to handle the conversions transparently and allow comparisons and joins across different types. Further, JET is case-insensitive, and the characters map differently in different language settings. And some sources need decryption as well. The decision to handle only the first 255 characters is a perfectly reasonable compromise for a desktop database like JET.
CROSSTAB QUERY TECHNIQUES This article explains a series of tips for crosstab queries. An example A crosstab query is a matrix, where the column headings come from the values in a field. In the example below, the product names appear down the left, the employee names become fields, and the intersection shows how many of this product has been sold by this employee:
To create this query, open the Northwind sample database, create a new query, switch to SQL View (View menu), and paste: TRANSFORM Sum([Order Details].Quantity) AS SumOfQuantity SELECT Products.ProductID, Products.ProductName, Sum([Order Details].Quantity) AS Total FROM Employees INNER JOIN (Products INNER JOIN (Orders INNER JOIN [Order Details] ON Orders.OrderID = [Order Details].OrderID) ON Products.ProductID = [Order Details].ProductID) ON Employees.EmployeeID = Orders.EmployeeID GROUP BY Products.ProductID, Products.ProductName PIVOT [Employees].[LastName] & ", " & [Employees].[FirstName];
Display row totals To show the total of all the columns in the row, just add the value field again as a Row Heading. In the example above, we used the Sum of the Quantity as the value. So, we added the Sum of Quantity again as a Row Heading - the right-most column in the screenshot. (The total displays to the left of the employee names.) In Access 2007 and later, you can also show the total at the bottom of each column, by depressing the Totals button on the ribbon. The button is on the Records group of the Home tab, and the icon is an upper case sigma (). Display zeros (not blanks) Where there are no values, the column is blank. Use Nz() if you want to show zeros instead. Since Access frequently misunderstands expressions, you should also typecast the result. Use CCur() for Currency, CLng() for a Long (whole number), or CDbl() for a Double (fractional number.) Type the Nz() directly into the TRANSFORM clause. For the example above, use: TRANSFORM CLng(Nz(Sum([Order Details].Quantity),0)) AS SumOfQuantity Handle parameters A query can ask you to supply a value at runtime. It pops up a parameter dialog if you enter something like this: [What order date] Or, it can read a value from a control on a form: [Forms].[Form1].[StartDate] But, parameters do not work with crosstab queries, unless you: a) Declare the parameter, or b) Specify the column headings. To declare the parameter, choose Parameters on the Query menu. Access opens a dialog. Enter the name and specify the data type. For the examples above, use the Query Parameters dialog like this: Parameter Data Type [What order date] Date/Time [Forms].[Form1].[StartDate] Date/Time
[ OK ] [ Cancel ] Declaring your parameters is always a good idea (except for an Access bug in handling parameters of type Text), but it is not essential if you specify your column headings. Specify column headings Since the column headings are derived from a field, you only get fields relevant to the data. So, if your criteria limits the query to a period when Nancy Davolio made no sales, her field will not be displayed. If your goal is to make a report from the crosstab, the report will give errors if the field named "Davolio, Nancy" just disappears. To solve this, enter all the valid column headings into the Column Headings property of the crosstab query. Steps: In query design view, show the Properties box (View menu.) Locate the Column Headings property. (If you don't see it, you are looking at the properties of a field instead of the properties of the query.) Type in all the possible values, separated by commas. Delimit text values with quotes, or date values with #. For the query above, set the Column Headings property like this (on one line): "Buchanan, Steven", "Callahan, Laura", "Davolio, Nancy", "Dodsworth, Anne", "Fuller, Andrew", "King, Robert", "Leverling, Janet", "Peacock, Margaret", "Suyama, Michael" Side effects of using column headings: Any values you do not list are excluded from the query. The fields will appear in the order you specify, e.g. "Jan", "Feb", "Mar", ... Where a report has a complex crosstab query as its Record Source, specifying the column headings can speed up the design of the report enormously. If you do not specify the column headings, Access is unable to determine the fields that will be available to the report without running the entire query. But if you specify the Column Headings, it can read the field names without running the query. An alternative approach is to alias the fields so the names don't change. Duane Hookom has an example of dynamic monthly crosstab reports. Multiple sets of values What if you want to show multiple sets of values at each matrix point? Say the crosstab shows products at the left, and months across the top, and you want to show both the dollar value and number of products sold at each intersection? One solution is to add another unjoined table to get the additional set of columns in your crosstab (a Cartesian product.) Try this example with the old Northwind sample database: Create a table with one Text field called FieldName. Mark the field as primary key. Save the table with the name tblXtabColumns. Enter two records: the values "Amt" and "Qty" (without the quotes.) Create a new query, and paste in the SQL statement below: TRANSFORM Sum(IIf([FieldName]="Qty",[Quantity],[Quantity]*[Order Details]![UnitPrice])) AS TheValue SELECT Products.ProductName FROM tblXtabColumns, Products INNER JOIN (Orders INNER JOIN [Order Details] ON Orders.OrderID = [Order Details].OrderID) ON Products.ProductID = [Order Details].ProductID WHERE (Orders.OrderDate Between #1/1/1998# And #3/31/1998#) GROUP BY Products.ProductName PIVOT [FieldName] & Month([OrderDate]); The query will look like this:
It generates fields named Amt and the month number, and Qty and the month number:
You can then lay them out as you wish on a report.
SUBQUERY BASICS Discovering subqueries is one of those "Eureka!" moments. A new landscape opens in front of you, and you can do really useful things such as: Read a value from the previous or next record in a table. Select just the TOP (or most recent) 5 scores per client. Choose the people who have not paid/ordered/enrolled in a period. Express a value as a percentage of the total. Avoid inflated totals where a record is repeated (due to multiple related records.) Filter or calculate values from other tables that are not even in the query.
What is a subquery? The SELECT query statement
This example shows basic SQL syntax. It returns 3 fields from 1 table, applies criteria, and sorts the results: SELECT CompanyID, Company, City FROM Table1 WHERE (City = "Springfield") ORDER BY Company; The clauses must be in the right order. Line endings and brackets are optional. A subquery is a SELECT query statement inside another query. As you drag fields and type expressions in query design, Access writes a sentence describing what you asked for. The statement is in SQL (see'quell) - Structured Query Language - the most common relational database language, also used by MySQL, SQL Server, Oracle, DB2, FoxPro, dBase, and others. If SQL is a foreign language, you can mock up a query like the subquery you need, switch it to SQL View, copy, and paste into SQL View in your main query. There will be some tidying up to do, but that's the simplest way to create a subquery. Subquery examples The best way to grasp subqueries is to look at examples of how to use them. Identifying what is NOT there A sales rep. wants to hound customers who have not placed any orders in the last 90 days: SELECT Customers.ID, Customers.Company FROM Customers WHERE NOT EXISTS (SELECT Orders.OrderID FROM Orders WHERE Orders.CustomerID = Customers.CustomerID AND Orders.OrderDate > Date() - 90) ; The main query selects two fields (ID and Company) from the Customers table. It is limited by the WHERE clause, which contains the subquery. The subquery (everything inside the brackets) selects Order ID from the Orders table, limited by two criteria: it has to be the same customer as the one being considered in the main query, and the Order Date has to be in the last 90 days. When the main query runs, Access examines each record in the Customers table. To decide whether to include the customer, it runs the subquery. The subquery finds any orders for that customer in the period. If it finds any, the customer is excluded by the NOT EXISTS. Points to note: The subquery goes in brackets, without a semicolon of its own. The Orders table is not even in the main query. Subqueries are ideal for querying about data in other tables. The subquery does not have the Customers table in its FROM clause, yet it can refer to values in the main query. Subqueries are useful for answering questions about what data exists or does not exist in a related table.
Get the value in another record Periodically, they read the meter at your house, and send a bill for the number of units used since the previous reading. The previous reading is a different record in the same table. How can they query that? A subquery can read another record in the same table, like this: SELECT MeterReading.ID, MeterReading.ReadDate, MeterReading.MeterValue, (SELECT TOP 1 Dupe.MeterValue FROM MeterReading AS Dupe WHERE Dupe.AddressID = MeterReading.AddressID AND Dupe.ReadDate < MeterReading.ReadDate ORDER BY Dupe.ReadDate DESC, Dupe.ID) AS PriorValue FROM MeterReading; The main query here contains 4 fields: the primary key, the reading date, the meter value at that date, and a fourth field that is the value returned from the subquery. The subquery returns just one meter reading (TOP 1.) The WHERE clause limits it to the same address, and a previous date. The ORDER BY clause sorts by descending date, so the most recent record will be the first one. Points to note: Since there are two copies of the same table, you must alias one of them. The example uses Dupe for the duplicate table, but any name will do. If the main query displays the result, the subquery must return a single value only. You get this error if it returns multiple values: At most one record can be returned by this subquery. Even though we asked for TOP 1, Access will return multiple records if there is a tie, e.g. if there were two meter readings on the same date. Include the primary key in the ORDER BY clause to ensure it can decide which one to return if there are equal values. The main query will be read-only (not editable.) That is always the case when the subquery shows a value in the main query (i.e. when the subquery is in the SELECT clause of the main query.) TOP n records per group You want the three most recent orders for each client. Use a subquery to select the 3 top orders per client, and use it to limit which orders are selected in the main query: SELECT Orders.CustomerID, Orders.OrderDate, Orders.OrderID FROM Orders WHERE Orders.OrderID IN (SELECT TOP 3 OrderID FROM Orders AS Dupe WHERE Dupe.CustomerID = Orders.CustomerID ORDER BY Dupe.OrderDate DESC, Dupe.OrderID DESC) ORDER BY Orders.CustomerID, Orders.OrderDate, Orders.OrderID; Points to note: Since we have two copies of the same table, we need the alias. Like EXISTS in the first example above, there is no problem with the subquery returning multiple records. The main query does not have to show any value from the subquery. Adding the primary key field to the ORDER BY clause differentiates between tied values. Year to date A Totals query easily gives you a total for the current month, but to get a year-to-date total or a total from the same month last year means another calculation from the same table but for a different period. A subquery is ideal for this purpose. SELECT Year([Orders].[OrderDate]) AS TheYear, Month([Orders].[OrderDate]) AS TheMonth, Sum([Order Details].[Quantity]*[Order Details].[UnitPrice]) AS MonthAmount, (SELECT Sum(OD.Quantity * OD.UnitPrice) AS YTD FROM Orders AS A INNER JOIN [Order Details] AS OD ON A.OrderID = OD.OrderID WHERE A.OrderDate >= DateSerial(Year([Orders].[OrderDate]),1,1) AND A.OrderDate < DateSerial(Year([Orders].[OrderDate]), Month([Orders].[OrderDate]) + 1, 1)) AS YTDAmount FROM Orders INNER JOIN [Order Details] ON Orders.OrderID = [Order Details].OrderID GROUP BY Year([Orders].[OrderDate]), Month([Orders].[OrderDate]); Points to note: The subquery uses the same tables, so aliases them as A (for Orders) and OD (for Order Details.) The date criteria are designed so you can easily modify them for financial years rather than calendar years. Even with several thousand records in Order Details, the query runs instantaneously. Delete unmatched records The Unmatched Query Wizard (first dialog when you create a new query) can help you identify records in one table that have no records in another. But if you try to delete the unmatched records, Access may respond with, Could not delete from specified tables. An alternative approach is to use a subquery to identify the records in the related table that have no match in the main table. This example deletes any records in tblInvoice that have no matching record in the tblInvoiceDetail table: DELETE FROM tblInvoice WHERE NOT EXISTS (SELECT InvoiceID FROM tblInvoiceDetail WHERE tblInvoiceDetail.InvoiceID = tblInvoice.InvoiceID);
Delete duplicate records This example uses a subquery to de-duplicate a table. "Duplicate" is defined as records that have the same values in Surname and FirstName. We keep the one that has the lowest primary key value (field ID.) DELETE FROM Table1 WHERE ID <> (SELECT Min(ID) AS MinOfID FROM Table1 AS Dupe WHERE (Dupe.Surname = Table1.Surname) AND (Dupe.FirstName = Table1.FirstName)); Nulls don't match each other, so if you want to treat pairs of Nulls as duplicates, use this approach: DELETE FROM Table1 WHERE ID <> (SELECT Min(ID) AS MinOfID FROM Table1 AS Dupe WHERE ((Dupe.Surname = Table1.Surname) OR (Dupe.Surname Is Null AND Table1.Surname Is Null)) AND ((Dupe.FirstName = Table1.FirstName) OR (Dupe.FirstName Is Null AND Table1.FirstName Is Null))); Aggregation: Counts and totals Instead of creating a query into another query, you can summarize data with a subquery. This example works with Northwind, to show how many distinct clients bought each product: SELECT Products.ProductID, Products.ProductName, Count(Q.CustomerID) AS HowManyCustomers FROM (SELECT DISTINCT ProductID, CustomerID FROM Orders INNER JOIN [Order Details] ON Orders.OrderID = [Order Details].OrderID) AS Q INNER JOIN Products ON Q.ProductID = Products.ProductID GROUP BY Products.ProductID, Products.ProductName; Points to note: The subquery is in the FROM clause, where it easily replaces another saved query. The subquery in the FROM clause can return multiple fields. The entire subquery is aliased (as Q in this example), so the main query can refer to (and aggregate) its fields. Requires Access 2000 or later. Filters and searches Since subqueries can look up tables that are not in the main query, they are very useful for filtering forms and reports. A Filter or WhereCondition is just a WHERE clause. A WHERE clause can contain a subquery. So, you can use a subquery to filter a form or report. You now have a way to filter a form or report on fields in tables that are not even in the RecordSource! In our first example above, the main query used only the Customers table, and the subquery filtered it to those who had no orders in the last 90 days. You could filter the Customers form in exactly the same way: 'Create a subquery as a filter string. strWhere = "NOT EXISTS (SELECT Orders.OrderID FROM Orders " & _ "WHERE (Orders.CustomerID = Customers.CustomerID) AND (Orders.OrderDate > Date() - 90))" 'Apply the string as the filter of the form that has only the Customers table. Forms!Customers.Filter = strWhere Forms!Cusomters.FilterOn = True 'Or, use the string to filter a report that has only the Customers table. DoCmd.OpenReport "Customers", acViewPreview, , strWhere This technique opens the door for writing incredibly powerful searches. Add subqueries to the basic techniques explained in the Search form article, and you can offer a search where the user can select criteria based on any related table in the whole database. The screenshot below is to whet your appetite for how you can use subqueries. The form is unbound, with each tab collecting criteria that will be applied against related tables. The final RESULTS tab offers to launch several reports which don't even have those tables. It does this by dynamically generating a huge WhereCondition string that consists of several subqueries. The reports are filtered by the subqueries in the string.
RANKING OR NUMBERING RECORDS JET does not have features to rank/number rows as some other SQL implementations do, so this article discusses some ways to do it. Numbering in a report To number records in a report, use the Sorting And Grouping to sort them in the correct order. You can then show a sequence number just by adding a text box with these properties: Control Source =1 Running Sum Over All This is the easiest way to get a ranking, and also the most efficient to execute. However, tied results may not be what you expect. If the third and fourth entries are tied, it displays them as 3 and 4, where you might want 1, 2, 3, 3, 5. Numbering in a form To add a row number to the records in a form, see Stephen Lebans Row Number. This solution has the same advantage (fast) and disadvantage (handling tied results) as the report example above. Ranking in a query To handle tied results correctly, count the number of records that beat the current row. The example below works with the Northwind sample database. The first query calculates the total value of each customer's orders (ignoring the Discount field.) The second query uses that to calculate how many customers had a higher total value: Value of all orders per customer Ranking customers by value
SELECT Orders.CustomerID, Sum([Quantity]*[UnitPrice]) AS TotalValue FROM Orders INNER JOIN [Order Details] ON Orders.OrderID = [Order Details].OrderID GROUP BY Orders.CustomerID; SELECT qryCustomerValue.CustomerID, qryCustomerValue.TotalValue, (SELECT Count([CustomerID]) AS HowMany FROM qryCustomerValue AS Dupe WHERE Dupe.TotalValue > qryCustomerValue.TotalValue) AS BeatenBy FROM qryCustomerValue; The first step is a query that gives a single record for whatever you are trying to rank (customers in this case.) This is the source for the ranking query, which uses a subquery to calculate how many beat the current record. The example above starts ranking at zero. Add 1 if you wish, i.e. WHERE Dupe.TotalValue > qryCustomerValue.TotalValue) + 1 AS BeatenBy Limitations It can be frustrating to do anything with the ranking query: Limitation Workaround You cannot sort by ranking. Build yet another query using qryCustomerValueRank as an input table. It is not easy to supply criteria to stacked queries. Have the query read the criteria from a form, e.g. [Forms].[Form1].[StartDate] A report based on the query is likely to fail with: Multi-level group-by not allowed. Use a DCount() expression instead of the subquery. (This is even slower to execute.) Results may be incorrect. If you strike this bug, force JET to materialize the subquery with: (SELECT TOP 100 PERCENT Count([CustomerID]) AS HowMany You may need to write the query results to a temporary table so you can use them efficiently. Ranking with a temporary table To use a temporary table for the query above: Create a table with these fields: Field Name Data Type Description CustomerID Number (Long Integer) Primary key TotalValue Currency the value being ranked BeatenBy Number (Long Integer) the ranking Change qryCustomerValueRank into an append query: - In Access 2007 and 2010, click Append on the Design tab of the ribbon. - In earlier versions, Append is on the Query menu. Since the primary key cannot be null, add criteria to qryCustomerValueRank under CustomerID: Is Not Null Use code to populate the temporary table, and optionally open the report you based on the temporary table: Sub cmdRank_Click() Dim db As DAO.Database Set db = CurrentDb() db.Execute "DELETE FROM MyTempTable;", dbFailOnError db.Execute "qryCustomerValueRank", dbFailOnError DoCmd.OpenReport "Report1", acViewPreview Set db = Nothing End Sub Using an AutoNumber instead If you are not concerned with how ties are handled, you could avoid the ranking query and just use an AutoNumber in your temp table to number the rows: Add an AutoNumber column to the temporary table. Change qryCustomerValue so it sorts Descending on the TotalValue. Change it into an Append query. To reset the AutoNumber after clearing out the temporary table, compact the database. Alternatively, reset the seed programmatically.
COMMON QUERY HURDLES This article addresses mistakes people often make that yield poor query performance. We assume you have set up relational tables, with primary keys, foreign keys, and indexes on the fields you search and sort on.
Use SQL rather than VBA JET/ACE (the query engine in Access) uses Structured Query Language (SQL), as many databases do. JET can also call Visual Basic for Applications code (VBA.) This radically extends the power of JET, but it makes no sense to call VBA if SQL can do the job. Is Null, not IsNull()
WHERE IsNull(Table1.Field1)
WHERE (Table1.Field1 Is Null) Is Null is native SQL. IsNull() is a VBA function call. There is never a valid reason to call IsNull() in a query, when SQL can evaluate it natively.
IIf(), not Nz()
SELECT Nz(Table1.Field1,0) AS Amount
SELECT IIf(Table1.Field1 Is Null, 0, Table1.Field1) AS Amount The Nz() function replaces Null with another value (usually a zero for numbers, or a zero-length string for text.) The new value is a Variant data type, and VBA tags it with a subtype: String, Long, Double, Date, or whatever. This is great in VBA: a function can return different subtypes at different times. But in a query, a column can be only be ONE data type. JET therefore treats Variants as Text, since anything (numbers, dates, characters, ...) is valid in a Text column. The visual clue that JET is treating the column as Text is the way it left-aligns. Numbers and dates display right- aligned. If you expected a numeric or date column, you now have serious problems. Text fields are evaluated character-by-character. So 2 is greater than 19, because the first character (the 2) is greater than the first character of the other text (the 1 in 19.) Similarly, 4/1/2009 comes after 1/1/2010 in a Text column, because 4 comes after 1. Alarm bells should ring as soon as you see a column left-aligned as Text, when you expected it handled numerically. Wrong records will be selected, and the sorting will be nonsense. You could use typecast the expression with another VBA function call, but a better solution would be to let JET do the work instead of calling VBA at all. Instead of: Nz(MyField,0) use: IIf(MyField Is Null, 0, MyField) Yes: it's a little more typing, but the benefits are: You avoid the function call to Nz(). You retain the intended data type. The criteria are applied correctly. The column sorts correctly. This principle applies not just to Nz(), but to any VBA function that returns a Variant. It's just that Nz() is the most common instance we see. (Note: JET's IIf() is much more efficient than the similarly named function in VBA. The VBA one wastes time calculating both the True and False parts, and generates errors if either part does not work out (even if that part is not needed.) The JET IIf() does not have these problems.)
Domain aggregate functions DLookup(), DSum(), etc are slow to execute. They involve VBA calls, Expression Service calls, and they waste resources (opening additional connections to the data file.) Particularly if JET must perform the operation on each row of a query, this really bogs things down. A subquery will be considerably faster than a domain aggregate function. In most cases, a stacked query will be faster yet (i.e. another saved query that you include as a "table" in this query.) There are times when a domain aggregate function is still the best solution you have (e.g. where you need editable results.) For those cases, it might help to use ELookup() instead of the built-in functions.
Craft your expressions to use indexes The query will be much faster if the database can use an index to select records or sort them. Here are two examples.
WHERE Year(Table1.MyDate) = 2008
WHERE (Table1.MyDate >= #1/1/2008#) AND (Table1.MyDate < #1/1/2009#) Criteria on calculated fields In the example at right, the Year() function looks easier, but this will execute much slower. For every record, JET makes a VBA function call, gets the result, and then scans the entire table to eliminate the records from other years. Without the function call, JET could use the index to instantaneously select the records for 2008. This will execute orders of magnitude faster. (You could use WHERE Table1.MyDate Between #1/1/2008# And #12/31/2008#, but this misses any dates on the final day that have a time component.) Particularly in criteria or sorting, avoid VBA calls so JET can use the index.
SELECT ClientID, Surname & ", " + FirstName AS FullName FROM tblClient ORDER BY Surname & ", " & FirstName;
SELECT ClientID, Surname & ", " + FirstName AS FullName FROM tblClient ORDER BY Surname, FirstName, ClientID; Sorting on concatenated fields Picture a combo box for selecting people by name. The ClientID is hidden, and Surname and FirstNameare concatenated into one column so the full name is displayed even when the combo is not dropped down. Do not sort by the concatenated field! Sort by the two fields, so JET can use the indexes on the fields to perform the sorting.
Optimize Totals queries The JET query optimizer is very good, so you may find that simple queries are fast without the tips in this section. It is still worth the effort to create the best queries you can, so they don't suddenly slow down when you modify them.
SELECT ClientID, Count(InvoiceID) AS HowMany FROM tblInvoice GROUP BY ClientID HAVING ClientID = 99;
SELECT ClientID, Count(InvoiceID) AS HowMany FROM tblInvoice WHERE ClientID = 99 GROUP BY ClientID; WHERE versus HAVING Totals queries (those with a GROUP BY clause) can have both a WHERE clause and a HAVING clause. The WHERE is executed first - before aggregation; the HAVING is executed afterwards - when the totals have been calculated. It makes sense, then, to put your criteria in the WHERE clause, and use the HAVING clause only when you must apply criteria on the aggregated totals. This is not obvious in the Access query designer. When you add a field to the design grid, Access sets the Total row to Group By, and the temptation is type your criteria under that. If you do, the criteria end up in the HAVING clause. To use the WHERE clause, add the field to the grid a second time, and choose Where in the Total row.
FIRST versus GROUP BY
SELECT EmployeeID, LastName, Notes FROM Employees GROUP BY EmployeeID, LastName, Notes;
SELECT EmployeeID, First(LastName) AS FirstOfLastName, First(Notes) AS FirstOfNotes FROM Employees GROUP BY EmployeeID; When you add a field to a Totals query, Access offers Group By in the Total row. The default behavior, therefore, is that Access must group on all these fields. A primary key is unique. So, if you group by the primary key field, there is no need to group by other fields in that table. You can optimize the query by choosing First instead of Group By in the Total row under the other fields. First allows JET to return the value from the first matching record, without needing to group by the field. This makes a major difference with Memo fields. If you GROUP BY a memo (Notes in the example), Access compares only the first 255 characters, and the rest are truncated! By choosing First instead ofGroup By, JET is free to return the entire memo field from the first match. So not only is it more efficient; it actually solves the the problem of memo fields being chopped off. (A downside of using First is that the fields are aliased, e.g. FirstOfNotes.)
Split your Access database into data and application Even if all your data is in Access itself, consider using linked tables. Store all the data tables in one MDB or ACCDB file - the data file - and the remaining objects (queries, forms, reports, macros, and modules) in a second MDB - the application file. In multi-user situations, each user receives a local copy of the application file, linked to the tables in the single remote data file. Why split? There are significant advantages to splitting your application: Maintenance: To update the program, just replace the application file. Since the data is in a separate file, no data is overwritten. Network Traffic: Loading the entire application (forms, controls, code, etc) across the network increases traffic making your interface slower.
RECONNECT ATTACHED TABLES ON START-UP If you have an Access application split into DATA.MDB and PRG.MDB (see Split your MDB file into data and application ), and move the files to a different directory, all tables need to be reconnected. Peter Vukovic's function handles the reconnection. If your data and program are in different folders, see Dev Ashish's solution: Relink Access tables from code Function Reconnect () '************************************************************** '* START YOUR APPLICATION (MACRO: AUTOEXEC) WITH THIS FUNCTION '* AND THIS PROGRAM WILL CHANGE THE CONNECTIONS AUTOMATICALLY '* WHEN THE 'DATA.MDB' AND THE 'PRG.MDB' '* ARE IN THE SAME DIRECTORY!!! '* PROGRAMMING BY PETER VUKOVIC, Germany '* 100700.1262@compuserve.com '* ************************************************************ Dim db As Database, source As String, path As String Dim dbsource As String, i As Integer, j As Integer
Set db = dbengine.Workspaces(0).Databases(0) '************************************************************* '* RECOGNIZE THE PATH * '*************************************************************
For i = Len(db.name) To 1 Step -1 If Mid(db.name, i, 1) = Chr(92) Then path = Mid(db.name, 1, i) 'MsgBox (path) Exit For End If Next '************************************************************* '* CHANGE THE PATH AND CONNECT AGAIN * '*************************************************************
For i = 0 To db.tabledefs.count - 1 If db.tabledefs(i).connect <> " " Then source = Mid(db.tabledefs(i).connect, 11) 'Debug.Print source For j = Len(source) To 1 Step -1 If Mid(source, j, 1) = Chr(92) Then dbsource = Mid(source, j + 1, Len(source)) source = Mid(source, 1, j) If source <> path Then db.tabledefs(i).connect = ";Database=" + path + dbsource db.tabledefs(i).RefreshLink 'Debug.Print ";Database=" + path + dbsource End If Exit For End If Next End If Next End Function
SELF JOINS Sometimes a field contains data which refers to another record in the same table. For example, employees may have a field called "Supervisor" containing the EmployeeID of the person who is their supervisor. To find out the supervisor's name, the table must look itself up. To ensure referential integrity, Access needs to know that only valid EmployeeIDs are allowed in the Supervisor field. This is achieved by dragging two copies of the Employees tableinto the Relationships screen, and then dragging SupervisorID from one onto EmployeeID in the other. You have just defined a self join. You will become quite accustomed to working with self-joins if you are asked to develop a report for printing pedigrees. The parents of a horse are themselves horses, and so will have their own records in the table of horses. A SireID field and a DamID field will each refer to different records in the same table. To define these two self-joins requires three copies of the table in the "Relationships" window. Now a full pedigree can be traced within a single table. Here are the steps to develop the query for the pedigree report: Drag three copies of tblHorses onto a new query. For your own sanity, select tblHorses_1 and change its alias property to Sire in the Properties window. Alias tblHorses_2 as Dam. Drag the SireID field from tblHorses to the ID field in Sire. Since we want the family tree even if some entries are missing, this needs to be an outer join, so double-click the line that defines the join and select 2 in the dialog box. Repeat step 2 to create an outer join between DamID in tblHorses and ID in Dam. Now drag four more copies of tblHorses into the query window, and alias them with names like SiresSire, SiresDam, DamsSire, and DamsDam. Create outer joins between these four tables, and the appropriate fields in Sire and Dam. Repeat steps 4 and 5 with eight more copies of the table for the next generation. Drag the desired output fields from these tables into the query grid, and your query is ready to view. Your query should end up like this:
And just in case you wish to create this query by copying the SQL, here it is: SELECT DISTINCTROW TblHorses.Name, Sire.Name, Dam.Name, SiresSire.Name, SiresDam.Name, DamsSire.Name, DamsDam.Name, SiresSiresSire.Name, SiresSiresDam.Name, SiresDamsSire.Name, SiresDamsDam.Name, DamsSiresSire.Name, DamsSiresDam.Name, DamsDamsSire.Name, DamsDamsDam.Name FROM (((((((((((((TblHorses LEFT JOIN TblHorses AS Sire ON TblHorses.SireID = Sire.ID) LEFT JOIN TblHorses AS Dam ON TblHorses.DamID = Dam.ID) LEFT JOIN TblHorses AS SiresSire ON Sire.SireID = SiresSire.ID) LEFT JOIN TblHorses AS SiresDam ON Sire.DamID = SiresDam.ID) LEFT JOIN TblHorses AS DamsSire ON Dam.SireID = DamsSire.ID) LEFT JOIN TblHorses AS DamsDam ON Dam.DamID = DamsDam.ID) LEFT JOIN TblHorses AS SiresSiresSire ON SiresSire.SireID = SiresSiresSire.ID) LEFT JOIN TblHorses AS SiresSiresDam ON SiresSire.DamID = SiresSiresDam.ID) LEFT JOIN TblHorses AS SiresDamsSire ON SiresDam.SireID = SiresDamsSire.ID) LEFT JOIN TblHorses AS SiresDamsDam ON SiresDam.DamID = SiresDamsDam.ID) LEFT JOIN TblHorses AS DamsSiresSire ON DamsSire.SireID = DamsSiresSire.ID) LEFT JOIN TblHorses AS DamsSiresDam ON DamsSire.DamID = DamsSiresDam.ID) LEFT JOIN TblHorses AS DamsDamsSire ON DamsDam.SireID = DamsDamsSire.ID) LEFT JOIN TblHorses AS DamsDamsDam ON DamsDam.DamID = DamsDamsDam.ID ORDER BY TblHorses.Name;
FIELD TYPE REFERENCE - NAMES AND VALUES FOR DDL, DAO, AND ADOX You can create and manage tables in Access using: the interface (table design view); Data Definition Language (DDL) query statements; DAO code; ADOX code. Each approach uses different names for the same field types. This reference provides a comparison. For calculated fields, Access 2010 reports the data type specified in the ResultType propety. For code to convert the DAO number into a field type name, see FieldTypeName(). JET (Interface) DDL (Queries) [1]
DAO constant / decimal / hex [2]
ADOX constant / decimal / hex Text [3]
TEXT (size) [4] dbText 10 A adVarWChar 202 CA dbComplexText 109 6D [5] CHAR (size) dbText [6] 10 A adWChar 130 82 Memo MEMO dbMemo 12 C adLongVarWChar 203 CB Number: Byte BYTE dbByte 2 2 adUnsignedTinyInt 17 11 dbComplexByte 102 66 Number: Integer SHORT dbInteger 3 3 adSmallInt 2 2 dbComplexInteger 103 67 Number: Long LONG dbLong 4 4 adInteger 3 3 dbComplexLong 104 68 Number: Single SINGLE dbSingle 6 6 adSingle 4 4 dbComplexSingle 105 69 Number: Double DOUBLE dbDouble 7 7 adDouble 5 5 dbComplexDouble 106 6A Number: Replica GUID dbGUID 15 F adGUID 72 48 dbComplexGUID 107 6B Number: Decimal DECIMAL (precision, scale) [7]
dbLong with attributes 4 4 adInteger with attributes 3 3 Yes/No YESNO dbBoolean 1 1 adBoolean 11 B OLE Object LONGBINARY dbLongBinary 11 B adLongVarBinary 205 CD Hyperlink [9]
dbMemo with attributes 12 C adLongVarWChar with attributes 203 CB Attachment dbAttachment 101 65 [10] BINARY (size) dbBinary 9 9 adVarBinary 204 CC
SET AUTONUMBERS TO START FROM ... Resetting an AutoNumber to 1 is easy: delete the records, and compact the database. But how do you force an AutoNumber to start from a specified value? The trick is to import a record with one less than the desired number, and then delete it. The following sub performs that operation. For example, to force table "tblClient" to begin numbering from 7500, enter: Call SetAutoNumber("tblClient", 7500)
Sub SetAutoNumber(sTable As String, ByVal lNum As Long) On Error GoTo Err_SetAutoNumber ' Purpose: set the AutoNumber field in sTable to begin at lNum. ' Arguments: sTable = name of table to modify. ' lNum = the number you wish to begin from. ' Sample use: Call SetAutoNumber("tblInvoice", 1000) Dim db As DAO.Database ' Current db. Dim tdf As DAO.TableDef ' TableDef of sTable. Dim i As Integer ' Loop counter Dim fld As DAO.Field ' Field of sTable. Dim sFieldName As String ' Name of the AutoNumber field. Dim vMaxID As Variant ' Current Maximum AutoNumber value. Dim sSQL As String ' Append/Delete query string. Dim sMsg As String ' MsgBox string.
lNum = lNum - 1 ' Assign to 1 less than desired value.
' Locate the auto-incrementing field for this table. Set db = CurrentDb() Set tdf = db.TableDefs(sTable) For i = 0 To tdf.Fields.Count - 1 Set fld = tdf.Fields(i) If fld.Attributes And dbAutoIncrField Then sFieldName = fld.name Exit For End If Next
If Len(sFieldName) = 0 Then sMsg = "No AutoNumber field found in table """ & sTable & """." MsgBox sMsg, vbInformation, "Cannot set AutoNumber" Else vMaxID = DMax(sFieldName, sTable) If IsNull(vMaxID) Then vMaxID = 0 If vMaxID >= lNum Then sMsg = "Supply a larger number. """ & sTable & "." & _ sFieldName & """ already contains the value " & vMaxID MsgBox sMsg, vbInformation, "Too low." Else ' Insert and delete the record. sSQL = "INSERT INTO " & sTable & " ([" & sFieldName & "]) SELECT " & lNum & " AS lNum;" db.Execute sSQL, dbFailOnError sSQL = "DELETE FROM " & sTable & " WHERE " & sFieldName & " = " & lNum & ";" db.Execute sSQL, dbFailOnError End If End If Exit_SetAutoNumber: Exit Sub
Err_SetAutoNumber: MsgBox "Error " & Err.Number & ": " & Err.Description, , "SetAutoNumber()" Resume Exit_SetAutoNumber End Sub
CUSTOM DATABASE PROPERTIES Often you get the situation where you want to store a single item of data in the database, eg an Author Name, a version, a language selection. The most usual way to do this is to define a global const in a module. This has two problems, it is not updatable, and it is not easily accessible from outside the database. A better solution is to make use of database properties. DAO objects - tables, querydefs, formdefs and the database itself have a list of properties. You can add user- defined properties to Database, Field, Index, QueryDef and TableDef objects. This is something you do once for the life of the object, so the best way to do it is via a bit of scrap code. To add (say) a Copyright Notice to the database, open a new module create a function named (say) tmp: Function Tmp() Dim DB As Database Dim P as Property Set DB = DBEngine(0)(0) Set P = DB.CreateProperty("Copyright Notice", DB_TEXT, "(C) JT Software 1995") DB.Properties.Append P End Function open the immediate window run tmp by entering ?tmp(). this will add the property to the DB run it again. This time it should give an error "Can't Append: Object already in collection" And that's it. Don't bother saving the function - the property is now a permanent part of the database. Now you need a function to get the copyright notice: Function CopyRight() Dim DB As Database Set DB = DBEngine(0)(0) CopyRight = DB.Properties![CopyRight Notice] End Function
The interesting thing is that you can fetch the copyright notice from a different database from the current one:
Function CopyRight(filename as string) Dim DB As Database Set DB=OpenDatabase(filename) CopyRight = DB.Properties![CopyRight Notice] DB.Close End Function
Perhaps a function to update the notice would be good too:
Function CopyRightUpd(filename as string) Dim DB As Database Set DB=OpenDatabase(filename) DB.Properties![CopyRight Notice] = "(C) JT Software " & Year(Now) DB.Close End Function
Tip 2.1 - Version control for split databases Database properties are the way I prefer to do version control of split databases. To each database I add the following properties: Product=Database for Section XYZ Component=GlobalData Version=3 Compat=2 This means that this database contains global data for the database I wrote for the guys in XYZ. It is version 3, but is compatible backward to version 2 (eg-just contains some longer field lengths on one of the tables). On opening the front-end database, I grab the name of the data database from the connect property of one of my linked tables, and then CheckCompat(extdb): Function CheckCompat (ext As String) As Integer Dim ws As WorkSpace Dim DB As Database Dim ver1 As Integer Dim compat1 As Integer Dim ver2 As Integer Dim compat2 As Integer Set ws = DBEngine(0) Set DB = ws(0) ver1 = db.properties!version compat1 = db.properties!compat
On Error Resume Next Set DB = ws.OpenDatabase(ext) If Err Then MsgBox "Can't open """ & ext & """: " & Error, 48 checkcompat = False Exit Function End If ver2 = db.properties!verversion compat2 = db.properties!compat If Err Then MsgBox "Can't check version on """ & ext & """t: " & Error, 48 checkcompat = False
Exit Function End If
If ver1 > ver2 And ver2 < compat1 Then MsgBox "Can't link the specified data file. This database requires a version " & Format(CDbl(compat1) / 100, "0.00") & " data file.", 48 checkcompat = False Exit Function ElseIf ver2 > ver1 And ver1 < compat2 Then MsgBox "Can't link the specified data file. It requires a version " & Format(CDbl(compat2) / 100, "0.00") & " forms database.", 48 checkcompat = False Exit Function End If
checkcompat = True End Function
If the checkcompat is OK, I then do a refreshlink on all the attached tables. The other properties are used when the user wants to link to a different data file. I check that the file they want to link to: 1 - Is an access database 2 - Is of the same product as the current database 3 - Is a "data" component (ie, not a forms component) 4 - has an appropriate version.
Tip 2.2 - Serial Numbers without using counters (aka: Can I reset a counter to zero?) There have been quite a few people on the comp.databases.ms-access newsgroup asking if you can reset a counter to zero. Briefly, not really. If you need as serial number or usage count that persists after the database is closed, a good way is to use a property named "SerialNo". As before, create a temporary function to create the property: Function Tmp() Dim DB As Database Set DB = DBEngine(0)(0) DB.properties.Append DB.CreateProperty("SerialNo", DB_LONG, 0) End Function And run it once from the immediate window. You then need one or two functions to access it Function CurrSerial() as Long Dim DB as DataBase Set DB = DBengine(0)(0) CurrSerial = DB.properties!SerialNo End Function
Function NextSerial() as Long Dim DB as DataBase Set DB = DBengine(0)(0) DB.properties!SerialNo = DB.properties!SerialNo + 1 NextSerial = DB.properties!SerialNo End Function
Sub ResetSerial() Dim DB as DataBase Set DB = DBengine(0)(0) DB.properties!SerialNo = 0 End Sub
ERROR HANDLING IN VBA Every function or sub should contain error handling. Without it, a user may be left viewing the faulty code in a full version of Access, while a run-time version just crashes. For a more detailed approach to error handling, see FMS' article on Error Handling and Debugging. The simplest approach is to display the Access error message and quit the procedure. Each procedure, then, will have this format (without the line numbers): 1 Sub|Function SomeName() 2 On Error GoTo Err_SomeName ' Initialize error handling. 3 ' Code to do something here. 4 Exit_SomeName: ' Label to resume after error. 5 Exit Sub|Function ' Exit before error handler. 6 Err_SomeName: ' Label to jump to on error. 7 MsgBox Err.Number & Err.Description ' Place error handling here. 8 Resume Exit_SomeName ' Pick up again and quit. 9 End Sub|Function For a task where several things could go wrong, lines 7~8 will be replaced with more detail: Select Case Err.Number Case 9999 ' Whatever number you anticipate. Resume Next ' Use this to just ignore the line. Case 999 Resume Exit_SomeName ' Use this to give up on the proc. Case Else ' Any unexpected error. Call LogError(Err.Number, Err.Description, "SomeName()") Resume Exit_SomeName End Select The Case Else in this example calls a custom function to write the error details to a table. This allows you to review the details after the error has been cleared. The table might be named "tLogError" and consist of: Field Name Data Type Description ErrorLogID AutoNumber Primary Key. ErrNumber Number Long Integer. The Access-generated error number. ErrDescription Text Size=255. The Access-generated error message. ErrDate Date/Time System Date and Time of error. Default: =Now() CallingProc Text Name of procedure that called LogError() UserName Text Name of User. ShowUser Yes/No Whether error data was displayed in MsgBox Parameters Text 255. Optional. Any parameters you wish to record.
Below is a procedure for writing to this table. It optionally allows recording the value of any variables/parameters at the time the error occurred. You can also opt to suppress the display of information about the error.
Function LogError(ByVal lngErrNumber As Long, ByVal strErrDescription As String, _ strCallingProc As String, Optional vParameters, Optional bShowUser As Boolean = True) As Boolean On Error GoTo Err_LogError ' Purpose: Generic error handler. ' Logs errors to table "tLogError". ' Arguments: lngErrNumber - value of Err.Number ' strErrDescription - value of Err.Description ' strCallingProc - name of sub|function that generated the error. ' vParameters - optional string: List of parameters to record. ' bShowUser - optional boolean: If False, suppresses display. ' Author: Allen Browne, allen@allenbrowne.com
Dim strMsg As String ' String for display in MsgBox Dim rst As DAO.Recordset ' The tLogError table
Select Case lngErrNumber Case 0 Debug.Print strCallingProc & " called error 0." Case 2501 ' Cancelled 'Do nothing. Case 3314, 2101, 2115 ' Can't save. If bShowUser Then strMsg = "Record cannot be saved at this time." & vbCrLf & _ "Complete the entry, or press <Esc> to undo." MsgBox strMsg, vbExclamation, strCallingProc End If Case Else If bShowUser Then strMsg = "Error " & lngErrNumber & ": " & strErrDescription MsgBox strMsg, vbExclamation, strCallingProc End If Set rst = CurrentDb.OpenRecordset("tLogError", , dbAppendOnly) rst.AddNew rst![ErrNumber] = lngErrNumber rst![ErrDescription] = Left$(strErrDescription, 255) rst![ErrDate] = Now() rst![CallingProc] = strCallingProc rst![UserName] = CurrentUser() rst![ShowUser] = bShowUser If Not IsMissing(vParameters) Then rst![Parameters] = Left(vParameters, 255) End If rst.Update rst.Close LogError = True End Select
Exit_LogError: Set rst = Nothing Exit Function
Err_LogError: strMsg = "An unexpected situation arose in your program." & vbCrLf & _ "Please write down the following details:" & vbCrLf & vbCrLf & _ "Calling Proc: " & strCallingProc & vbCrLf & _ "Error Number " & lngErrNumber & vbCrLf & strErrDescription & vbCrLf & vbCrLf & _ "Unable to record because Error " & Err.Number & vbCrLf & Err.Description MsgBox strMsg, vbCritical, "LogError()" Resume Exit_LogError End Function
EXTENDED DLOOKUP() The DLookup() function in Access retrieves a value from a table. For basic information on how to use DLookup(), see Getting a value from a table. Why a replacement? DLookup() has several shortcomings: It just returns the first match to finds. Since you cannot specify a sort order, the result is unpredictable. You may even get inconsistent results from the same data (e.g. after compacting a database, if the table contains no primary key). Its performance is poor. It does not clean up after itself (can result in Not enough databases/tables errors). It returns the wrong answer if the target field contains a zero-length string. ELookup() addresses those limitations: An additional optional argument allows you to specify a sort order. That means you can specify which value to retrieve: the min or max value based on any sort order you wish to specify. It explicitly cleans up after itself. It runs about twice as fast as DLookup(). (Note that if you are retrieving a value for every row of a query, a subquery would provide much better performance.) It correctly differentiates a Null and a zero-length string. Limitations of ELookup(): If you ask ELookup() to concatenate several (not memo) fields, and more than 255 characters are returned, you strike this Access bug: Concatenated fields yield garbage in recordset. DLookup() can call the expression service to resolve an argument such as: DLookup("Surname", "Clients", "ClientID = [Forms].[Form1].[ClientID]") You can resolve the last issue by concatenating the value into the string: ELookup("Surname", "Clients", "ClientID = " & [Forms].[Form1].[ClientID]) Before using ELookup() in a query, you may want to modify it so it does not pop up a MsgBox for every row if you get the syntax wrong. Alternatively, if you don't mind a read-only result, a subquery would give you faster results than any function. How does it work? The function accepts exactly the same arguments as DLookup(), with an optional fourth argument. It builds a query string: SELECT Expr FROM Domain WHERE Criteria ORDER BY OrderClause This string opens a recordset. If the value returned is an object, the requested expression is a multi-value field, so we loop through the multiple values to return a delimited list. Otherwise it returns the first value found, or Null if there are no matches. Note that ELookup() requires a reference to the DAO library. For information on setting a reference, see References.
Public Function ELookup(Expr As String, Domain As String, Optional Criteria As Variant, _ Optional OrderClause As Variant) As Variant On Error GoTo Err_ELookup 'Purpose: Faster and more flexible replacement for DLookup() 'Arguments: Same as DLookup, with additional Order By option. 'Return: Value of the Expr if found, else Null. ' Delimited list for multi-value field. 'Author: Allen Browne. allen@allenbrowne.com 'Updated: December 2006, to handle multi-value fields (Access 2007 and later.) 'Examples: ' 1. To find the last value, include DESC in the OrderClause, e.g.: ' ELookup("[Surname] & [FirstName]", "tblClient", , "ClientID DESC") ' 2. To find the lowest non-null value of a field, use the Criteria, e.g.: ' ELookup("ClientID", "tblClient", "Surname Is Not Null" , "Surname") 'Note: Requires a reference to the DAO library. Dim db As DAO.Database 'This database. Dim rs As DAO.Recordset 'To retrieve the value to find. Dim rsMVF As DAO.Recordset 'Child recordset to use for multi-value fields. Dim varResult As Variant 'Return value for function. Dim strSql As String 'SQL statement. Dim strOut As String 'Output string to build up (multi-value field.) Dim lngLen As Long 'Length of string. Const strcSep = "," 'Separator between items in multi-value list.
'Initialize to null. varResult = Null
'Build the SQL string. strSql = "SELECT TOP 1 " & Expr & " FROM " & Domain If Not IsMissing(Criteria) Then strSql = strSql & " WHERE " & Criteria End If If Not IsMissing(OrderClause) Then strSql = strSql & " ORDER BY " & OrderClause End If strSql = strSql & ";"
'Lookup the value. Set db = DBEngine(0)(0) Set rs = db.OpenRecordset(strSql, dbOpenForwardOnly) If rs.RecordCount > 0 Then 'Will be an object if multi-value field. If VarType(rs(0)) = vbObject Then Set rsMVF = rs(0).Value Do While Not rsMVF.EOF If rs(0).Type = 101 Then 'dbAttachment strOut = strOut & rsMVF!FileName & strcSep Else strOut = strOut & rsMVF![Value].Value & strcSep End If rsMVF.MoveNext Loop 'Remove trailing separator. lngLen = Len(strOut) - Len(strcSep) If lngLen > 0& Then varResult = Left(strOut, lngLen) End If Set rsMVF = Nothing Else 'Not a multi-value field: just return the value. varResult = rs(0) End If End If rs.Close
'Assign the return value. ELookup = varResult
Exit_ELookup: Set rs = Nothing Set db = Nothing Exit Function
Err_ELookup: MsgBox Err.Description, vbExclamation, "ELookup Error " & Err.number Resume Exit_ELookup End Function
EXTENDED DCOUNT() The built-in function - DCount() - cannot count the number of distinct values. The domain aggregate functions in Access are also quite inefficient. ECount() offers an extra argument so you can count distinct values. The other arguments are the same as DCount(). Using ECount() Paste the code below into a standard module. To verify Access understands it, choose Compile from the Debug menu (in the code window.) In Access 2000 or 2002, you may need to add a reference to the DAO library. You can then use the function anywhere you can use DCount(), such as in the Control Source of a text box on a form or report. Use square brackets around your field/table name if it contains a space or other strange character, or starts with a number. Examples These examples show how you could use ECount() in the Immediate Window (Ctrl+G) in the Northwind database: Expression Meaning ? ECount("*", "Customers") Number of customers. ? ECount("Fax", "Customers") Number of customers who have a fax number. ? ECount("*", "Customers", "Country = 'Spain'") Number of customers from Spain. ? ECount("City", "Customers", "Country = 'Spain'", True) Number of Spanish cities where we have customers. ? ECount("Region", "Customers") Number of customers who have a region. ? ECount("Region", "Customers", ,True) Number of distinct regions ? ECount("*", "Customers", "Region Is Null") Number of customers who have no region. You cannot embed a reference to a form in the arguments. For example, this will not work: ? ECount("*", "Customers", "City = Forms!Customers!City") Instead, concatenate the value into the string: ? ECount("*", "Customers", "City = """ & Forms!Customers!City & """") If you need help with the quotes, see Quotation marks within quotes. The code
Public Function ECount(Expr As String, Domain As String, Optional Criteria As String, Optional bCountDistinct As Boolean) As Variant
On Error GoTo Err_Handler 'Purpose: Enhanced DCount() function, with the ability to count distinct. 'Return: Number of records. Null on error. 'Arguments: Expr = name of the field to count. Use square brackets if the name contains a space. ' Domain = name of the table or query. ' Criteria = any restrictions. Can omit. ' bCountDistinct = True to return the number of distinct values in the field. Omit for normal count. 'Notes: Nulls are excluded (whether distinct count or not.) ' Use "*" for Expr if you want to count the nulls too. ' You cannot use "*" if bCountDistinct is True. 'Examples: Number of customers who have a region: ECount("Region", "Customers") ' Number of customers who have no region: ECount("*", "Customers", "Region Is Null") ' Number of distinct regions: ECount("Region", "Customers", ,True) Dim db As DAO.Database Dim rs As DAO.Recordset Dim strSql As String
'Initialize to return Null on error. ECount = Null Set db = DBEngine(0)(0)
If bCountDistinct Then 'Count distinct values. If Expr <> "*" Then 'Cannot count distinct 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 'Return the number of 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 'Return the count. End If rs.Close End If
Exit_Handler: Set rs = Nothing Set db = Nothing Exit Function
Err_Handler: MsgBox Err.Description, vbExclamation, "ECount Error " & Err.Number Resume Exit_Handler End Function
EXTENDED DAVG() The DAvg() function built into Access lets you get the average of a field in a table, and optionally specify criteria. This EAvg() function extends that functionality, so you can get the average of just the TOP values (or percentage) from the field. You can even specify a different field for sorting, e.g. to get the average of the 4 most recent values. Using EAvg() Paste the code below into a standard module. To verify Access understands it, choose Compile from the Debug menu (in the code window.) In Access 2000 or 2002, you may need to add a reference to the DAO library. You can then use the function anywhere you can use DAvg(), such as in the Control Source of a text box on a form or report. Use square brackets around your field/table name if it contains a space or other strange character, or starts with a number. The arguments to supply are: strExpr: the field name or expression to average. Examples These examples show how you could use EAvg() in the Immediate Window (Ctrl+G) in the Northwind database: Expression Meaning ? EAvg("Quantity", "[Order Details]") Average quantity in all orders. ? EAvg("Quantity", "[Order Details]", , 4) Average quantity of the 4 top orders. ? EAvg("[Quantity] * [UnitPrice]", "[Order Details]", , 5) Average dollar value of the top 5 line items. ? EAvg("Freight", "Orders", , 0.25) Average of the 25% highest freight values. ? EAvg("Freight", "Orders", "Freight > 0", 8, "OrderDate DESC, OrderID DESC") Average freight in the 8 most recent orders that have freight. The code
Public Function EAvg(strExpr As String, strDomain As String, Optional strCriteria As String, _ Optional dblTop As Double, Optional strOrderBy As String) As Variant On Error GoTo Err_Error 'Purpose: Extended replacement for DAvg(). 'Author: Allen Browne (allen@allenbrowne.com), November 2006. 'Requires: Access 2000 and later. 'Return: Average of the field in the domain. Null on error. 'Arguments: strExpr = the field name to average. ' strDomain = the table or query to use. ' strCriteria = WHERE clause limiting the records. ' dblTop = TOP number of records to average. Ignored if zero or negative. ' Treated as a percent if less 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.
lngTopAsPercent = 100# * dblTop If lngTopAsPercent > 0& Then 'There is a TOP predicate If lngTopAsPercent < 100& Then 'Less than 1, so treat as percent. strSql = "SELECT Avg(" & strExpr & ") AS TheAverage " & vbCrLf & _ "FROM (SELECT TOP " & lngTopAsPercent & " PERCENT " & strExpr Else 'More than 1, so treat as count. strSql = "SELECT Avg(" & strExpr & ") AS TheAverage " & vbCrLf & _ "FROM (SELECT TOP " & CLng(dblTop) & " " & strExpr End If strSql = strSql & " " & vbCrLf & " FROM " & strDomain & " " & vbCrLf & _ " WHERE (" & strExpr & " Is Not Null)" If strCriteria <> vbNullString Then strSql = strSql & vbCrLf & " AND (" & strCriteria & ") " End If If strOrderBy <> vbNullString Then strSql = strSql & vbCrLf & " ORDER BY " & strOrderBy & ") AS MySubquery;" Else strSql = strSql & vbCrLf & " ORDER BY " & strExpr & " DESC) AS MySubquery;" End If Else 'There is no TOP predicate (so we also ignore any ORDER BY.) strSql = "SELECT Avg(" & strExpr & ") AS TheAverage " & vbCrLf & _ "FROM " & strDomain & " " & vbCrLf & "WHERE (" & strExpr & " Is Not Null)" If strCriteria <> vbNullString Then strSql = strSql & vbCrLf & " AND (" & strCriteria & ")" End If strSql = strSql & ";" End If
Set rs = DBEngine(0)(0).OpenRecordset(strSql) If rs.RecordCount > 0& Then EAvg = rs!TheAverage End If rs.Close
Exit_Handler: Set rs = Nothing Exit Function
Err_Error: MsgBox "Error " & Err.Number & ": " & Err.Description, , "EAvg()" Resume Exit_Handler End Function
ARCHIVE: MOVE RECORDS TO ANOTHER TABLE A move consists of two action queries: Append and Delete. A transaction blocks the Delete if the Append did not succeed. Transactions are not difficult, but there are several pitfalls. Should I archive? Probably not. If possible, keep the old records in the same table with the current ones, and use a field to distinguish their status. This makes it much easier to query the data, compare current with old values, etc. It's possible to get the data from different tables back together again with UNION statements, but it's slower, can't be displayed as a graphic query, and the results are read-only. Archiving is best reserved for cases where you won't ever need the old data, or there are overriding considerations e.g. hundreds of thousands of records, with new ones being added constantly. The archive table will probably be in a separate database. The Steps The procedure below consists of these steps: Start a transaction. Execute the append query. Execute the delete query. Get user confirmation to commit the change. If anything went wrong at any step, roll back the transaction. The Traps Watch out for these serious traps when working with transactions: Use dbFailOnError with the Execute method. Otherwise you are not notified of any errors, and the results could be incomplete. dbFailOnError without a transaction is not enough. In Access 95 and earlier, dbFailOnError rolled the entire operation back, and the Access 97 help file wrongly claims that is still the case. (There is a correction in the readme.) FromAccess 97 onwards, dbFailOnError stops further processing when an error occurs, but everything up to the point where the error occurred is committed. Don't close the default workspace! The default workspace--dbEngine(0)--is always open. You will set a reference to it, but you are not opening it. Access will allow you to close it, but later you will receive unrelated errors about objects that are no longer set or have gone out of scope. Remember: Close only what you open; set all objects to nothing. CommitTrans or Rollback, even after an error. The default workspace is always open, so an unterminated transaction remains active even after your procedure ends! And since Access supports multiple transactions, you can dig yourself in further and further. Error handling is essential, with the rollback in the error recovery section. A flag indicating whether you have a transaction open is a practical way to manage this. The Code This example selects the records from MyTable where the field MyYesNoField is Yes, and moves them into a table named MyArchiveTable in a different database file - C:\My Documents\MyArchive.mdb. Note: Requires a reference to the DAO library.
Sub DoArchive() On Error GoTo Err_DoArchive Dim ws As DAO.Workspace 'Current workspace (for transaction). Dim db As DAO.Database 'Inside the transaction. Dim bInTrans As Boolean 'Flag that transaction is active. Dim strSql As String 'Action query statements. Dim strMsg As String 'MsgBox message.
'Step 1: Initialize database object inside a transaction. Set ws = DBEngine(0) ws.BeginTrans bInTrans = True Set db = ws(0)
'Step 2: Execute the append. strSql = "INSERT INTO MyArchiveTable ( MyField, AnotherField, Field3 ) " & _ "IN ""C:\My Documents\MyArchive.mdb"" " & _ "SELECT SomeField, Field2, Field3 FROM MyTable WHERE (MyYesNoField = True);" db.Execute strSql, dbFailOnError
'Step 3: Execute the delete. strSql = "DELETE FROM MyTable WHERE (MyYesNoField = True);" db.Execute strSql, dbFailOnError
'Step 4: Get user confirmation to commit the change. strMsg = "Archive " & db.RecordsAffected & " record(s)?" If MsgBox(strMsg, vbOKCancel + vbQuestion, "Confirm") = vbOK Then ws.CommitTrans bInTrans = False End If
Exit_DoArchive: 'Step 5: Clean up On Error Resume Next Set db = Nothing If bInTrans Then 'Rollback if the transaction is active. ws.Rollback End If Set ws = Nothing Exit Sub
Err_DoArchive: MsgBox Err.Description, vbExclamation, "Archiving failed: Error " & Err.number Resume Exit_DoArchive End Sub
LIST FILES RECURSIVELY This article illustrates how to list files recursively in VBA. Output can be listed to the immediate window, or (in Access 2002 or later) added to a list box. See List files to a table if you would prefer to add the files to a table rather than list box. See DirListBox() for Access 97 or earlier. Or, Doug Steele offers some alternative solutions in Find Your Data. Using the code To add the code to your database: Create a new module. In Access 2007 and later, click Module (right-most icon) on the Create ribbon. In older versions, click the Modules tab of the database window, and click New. Access opens the code window. Copy the code below, and paste into your new module. Choose Compile in the Debug menu, to verify Access understands the code. Save the module with a name such as ajbFileList. In the Immediate window To list the files in C:\Data, open the Immediate Window (Ctrl+G), and enter: Call ListFiles("C:\Data") To limit the results to zip files: Call ListFiles("C:\Data", "*.zip") To include files in subdirectories as well: Call ListFiles("C:\Data", , True) In a list box To show the files in a list box: Create a new form. Add a list box, and set these properties: Name lstFileList Row Source Type Value List Set the On Load property of the form to: [Event Procedure] Click the Build button (...) beside this. Access opens the code window. Set up the event procedure like this: Private Sub Form_Load() Call ListFiles("C:\Data", , , Me.lstFileList) End Sub
The Code Public Function ListFiles(strPath As String, Optional strFileSpec As String, _ Optional bIncludeSubfolders As Boolean, Optional lst As ListBox) On Error GoTo Err_Handler 'Purpose: List the files in the path. 'Arguments: strPath = the path to search. ' strFileSpec = "*.*" unless you specify differently. ' 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 For Each varItem In colDirList lst.AddItem varItem Next End If
Exit_Handler: Exit Function
Err_Handler: MsgBox "Error " & Err.Number & ": " & Err.Description Resume Exit_Handler End Function
Private Function FillDir(colDirList As Collection, ByVal strFolder As String, strFileSpec As String, _ bIncludeSubfolders As Boolean) 'Build up a list of files, and then add add to this list, any additional folders Dim strTemp As String Dim colFolders As New Collection Dim vFolderName As Variant
'Add the files to the folder. strFolder = TrailingSlash(strFolder) strTemp = Dir(strFolder & strFileSpec) Do While strTemp <> vbNullString colDirList.Add strFolder & strTemp strTemp = Dir Loop
If bIncludeSubfolders Then 'Build collection of additional subfolders. strTemp = Dir(strFolder, vbDirectory) Do While strTemp <> vbNullString If (strTemp <> ".") And (strTemp <> "..") Then If (GetAttr(strFolder & strTemp) And vbDirectory) <> 0& Then colFolders.Add strTemp End If End If strTemp = Dir Loop 'Call function recursively for each subfolder. For Each vFolderName In colFolders Call FillDir(colDirList, strFolder & TrailingSlash(vFolderName), strFileSpec, True) Next vFolderName End If End Function
Public Function TrailingSlash(varIn As Variant) As String If Len(varIn) > 0& Then If Right(varIn, 1&) = "\" Then TrailingSlash = varIn Else TrailingSlash = varIn & "\" End If End If End Function
ENABLING/DISABLING CONTROLS, BASED ON USER SECURITY In conjunction with using security work groups to limit/permit functionality to individual users, controls may be enabled/ disabled at run time. Otherwise users will have to view a warning message box from Access, when they try to do something they're not allowed to do. Note that the permission assignments for workgroups are by table, query, form, macro, etc. So this type of routine must be used to 'set - permissions' for individual controls. As in the example below, "viewers" must have permission to see the mainswitch board form, but it is necessary to disable buttons on that form. To do this: create a table with username (key) and workgroup create a usersform (autoform is good enough) based on table open form in autoexe (hidden) to where condition [username]=CurrentUser() set values of controls with On Open property based on usergroup. For example: If mainswtich board form has buttons to add, edit and report on records,you may set up a workgroup of accounts that may only report, called viewers. To disable the add and edit buttons use the OnOpen property of the mainswtich board form to run the following: if condition: [Forms]![usersform]![workgroup]="viewers"
setvalue: [Forms]![mainswitch]![reportsbutton].[Enabled] Yes [Forms]![mainswitch]![addbutton].[Enabled] No [Forms]![mainswitch]![editbutton].[Enabled] No This will 'gray-out' and disable the add and edit buttons.
Returning more than one value from a function A function can only have one return value. In Access 2, there were a couple of ways to work around this limitation: Use a parameter to define what you want returned. For example: Function MultiMode(iMode As Integer) As String Select Case iMode Case 1 MultiMode = "Value for first Option" Case 2 MultiMode = "Value for second Option" Case Else MultiMode = "Error" End Select End Function Another alternative was to pass arguments whose only purpose was so the function could alter them: Function MultiArgu(i1, i2, i3) i1 = "First Return Value" i2 = "Second Return Value" i3 = "Third Return Value" End Function VBA (Access 95 onwards) allows you to return an entire structure of values. In database terms, this is analogous to returning an entire record rather than a single field. For example, imagine an accounting database that needs to summarize income by the categories Wages, Dividends, and Other. VBA allows you to declare a user-defined type to handle this structure: Public Type Income Wages As Currency Dividends As Currency Other As Currency Total As Currency End Type You can now use this structure as the return type for a function. In a real situation, the function would look up your database tables to get the values, but the return values would be assigned like this: Function GetIncome() As Income GetIncome.Wages = 950 GetIncome.Dividends = 570 GetIncome.Other = 52 GetIncome.Total = GetIncome.Wages + GetIncome.Dividends + GetIncome.Other End Function To use the function, you could type into the Immediate Window: GetIncome().Wages (Note: the use of "Public" in the Type declaration gives it sufficient scope.) Programmers with a background in C will instantly recognize the possibilities now that user-defined types can be returned from functions. If you're keen, user-defined types can even be based on other user-defined types.
Copy SQL statement from query to VBA
Rather than typing complex query statements into VBA code, developers often mock up a query graphically, switch it to SQL View, copy, and paste into VBA. If you've done it, you know how messy it is sorting out the quotes, and the line endings. Solution: create a form where you paste the SQL statement, and get Access to create the SQL string for you. Creating the form The form just needs two text boxes, and a command button. SQL statements can be quite long, so you put the text boxes on different pages of a tab control. Create a new form (in design view.) Add a tab control. In the first page of the tab control, add a unbound text box. Set its Name property to txtSql. Increase its Height and Width so you can see many long lines at once. In the second page of the tab control, add another unbound text box. Name it txtVBA, and increase its height and width. Above the tab control, add a command button. Name it cmdSql2Vba. Set its On Click property to [Event Procedure]. Click the Build button (...) beside this property. When Access opens the code window, set up the code like this: Private Sub cmdSql2Vba_Click() Dim strSql As String 'Purpose: Convert a SQL statement into a string to paste 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
Using the form To use the form: Open your query in SQL View, and copy the SQL statement to clipboard (Ctrl+C.) Paste into the first text box (Ctrl+V.) Click the button. Paste into a new line in your VBA procedure (Ctrl+V.) Hint: If you want extra line breaks in your VBA code, press Enter to create those line breaks in the SQL View of the query or in your form.
CONCATENATE VALUES FROM RELATED RECORDS You have set up a one-to-many relationship, and now you want a query to show the records from the table on the ONE side, with the items from the MANY side beside each one. For example if one company has many orders, and you want to list the order dates like this: Company Order Dates Acme Corporation 1/1/2007, 3/1/2007, 7/1/2000, 1/1/2008 Wright Time Pty Ltd 4/4/2007, 9/9/2007 Zoological Parasites JET SQL does not provide an easy way to do this. A VBA function call is the simplest solution. How to use the function Add the function to your database: In Access, open the code window (e.g. press Ctrl+G.) On the Insert menu, click Module. Access opens a new module window. Paste in the function below. On the Debug menu, click Compile, to ensure Access understands it. You can then use it just like any of the built-in functions, e.g. in a calculated query field, in the ControlSource of a text box on a form or report, in a macro or in other code. For the example above, you could set the ControlSource of a text box to: =ConcatRelated("OrderDate", "tblOrders", "CompanyID = " & [CompanyID]) or in a query: SELECT CompanyName, ConcatRelated("OrderDate", "tblOrders", "CompanyID = " & [CompanyID]) FROM tblCompany; Bug warning: If the function returns more than 255 characters, and you use it in a query as the source for another recordset, a bug in Access may return garbage for the remaining characters. The arguments Inside the brackets for ConcatRelated(), place this information: First is the name of the field to look in. Include square brackets if the field contains non-alphanumeric characters such as a space, e.g. "[Order Date]" Second is the name of the table or query to look in. Again, use square brackets around the name if it contains spaces. Thirdly, supply the filter to limit the function to the desired values. This will normally be of the form: "[ForeignKeyFieldName] = " & [PrimaryKeyFieldName] If the foreign key field is Text (not Number), include quote marks as delimiters, e.g.: "[ForeignKeyFieldName] = """ & [PrimaryKeyFieldName] & """" For an explanation of the quotes, see Quotation marks within quotes. Any valid WHERE clause is permitted. If you omit this argument, ALL related records will be returned. Leave the fourth argument blank if you don't care how the return values are sorted. Specify the field name(s) to sort by those fields. Any valid ORDER BY clause is permitted. For example, to sort by [Order Date] with a secondary sort by [Order ID], use: "[Order Date], [Order ID]" You cannot sort by a multi-valued field. Use the fifth argument to specify the separator to use between items in the string. The default separator is a comma and space.
Public Function ConcatRelated(strField As String, _ strTable As String, _ Optional strWhere As String, _ Optional strOrderBy As String, _ Optional strSeparator = ", ") As Variant On Error GoTo Err_Handler 'Purpose: Generate a concatenated string of related records. 'Return: String variant, or Null if no matches. 'Arguments: strField = name of field to get results from and concatenate. ' strTable = name of a table or query. ' strWhere = WHERE clause to choose the right values. ' strOrderBy = ORDER BY clause, for sorting the 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 'Related records Dim rsMV As DAO.Recordset 'Multi-valued field recordset Dim strSql As String 'SQL statement Dim strOut As String 'Output string to concatenate to. Dim lngLen As Long 'Length of string. Dim bIsMultiValue As Boolean 'Flag if strField is a multi-valued field.
'Initialize to Null ConcatRelated = Null
'Build SQL string, and get the records. strSql = "SELECT " & strField & " FROM " & strTable If strWhere <> vbNullString Then strSql = strSql & " WHERE " & strWhere End If If strOrderBy <> vbNullString Then strSql = strSql & " ORDER BY " & strOrderBy End If Set rs = DBEngine(0)(0).OpenRecordset(strSql, dbOpenDynaset) 'Determine if the requested field is multi-valued (Type is above 100.) bIsMultiValue = (rs(0).Type > 100)
'Loop through the matching records Do While Not rs.EOF If bIsMultiValue Then 'For multi-valued field, loop through the values Set rsMV = rs(0).Value Do While Not rsMV.EOF If Not IsNull(rsMV(0)) Then strOut = strOut & rsMV(0) & strSeparator End If rsMV.MoveNext Loop Set rsMV = Nothing ElseIf Not IsNull(rs(0)) Then strOut = strOut & rs(0) & strSeparator End If rs.MoveNext Loop rs.Close
'Return the string without the trailing separator. lngLen = Len(strOut) - Len(strSeparator) If lngLen > 0 Then ConcatRelated = Left(strOut, lngLen) End If
Exit_Handler: 'Clean up Set rsMV = Nothing Set rs = Nothing Exit Function
Err_Handler: MsgBox "Error " & Err.Number & ": " & Err.Description, vbExclamation, "ConcatRelated()" Resume Exit_Handler End Function
MINOFLIST() AND MAXOFLIST() FUNCTIONS Access does not have functions like Min() and Max() in Excel, for selecting the least/greatest value from a list. That makes sense in a relational database, because you store these values in a related table. So Access provides DMin() and DMax() for retrieving the smallest/largest value from the column in the related table. Occasionally, you still need to pick the minimum or maximum value from a list. The functions below do that. They work with numeric fields, including currency and dates. They return Null if there was no numeric value in the list. Using the functions To create them: Create a new module. In Access 97 - 2003, click the Modules tab of the database window, and click New. In Access 2007 and later, click the Create ribbon, and choose Module (the rightmost icon on the Other group.) Access opens the code window. Copy the code below, and paste into your code window. Check that Access understands the code, by choosing Compile on the Debug menu. Save the module with a name such as Module1. Use them like any built-in function. For example, you could put this in a text box: =MinOfList(5, -3, Null, 0, 2) Or you could type this into a fresh column of the Field row in a query that has three date fields: MaxOfList([OrderDate], [InvoiceDate], [DueDate])
Function MinOfList(ParamArray varValues()) As Variant Dim i As Integer 'Loop controller. Dim varMin As Variant 'Smallest value found so far.
varMin = Null 'Initialize to null
For i = LBound(varValues) To UBound(varValues) If IsNumeric(varValues(i)) Or IsDate(varValues(i)) Then If varMin <= varValues(i) Then 'do nothing Else varMin = varValues(i) End If End If Next
MinOfList = varMin End Function
Function MaxOfList(ParamArray varValues()) As Variant Dim i As Integer 'Loop controller. Dim varMax As Variant 'Largest value found so far.
varMax = Null 'Initialize to null
For i = LBound(varValues) To UBound(varValues) If IsNumeric(varValues(i)) Or IsDate(varValues(i)) Then If varMax >= varValues(i) Then 'do nothing Else varMax = varValues(i) End If End If Next
MaxOfList = varMax End Function
Understanding the functions The ParamArray keyword lets you pass in any number of values. The function receives them as an array. You can then examine each value in the array to find the highest or lowest. TheLBound() and UBound() functions indicate how many values were passed in, and the loop visits each member in the array. Any nulls in the list are ignored: they do not pass the IsNumeric() test. The return value (varMin or VarMax) is initialized to Null, so the function returns Null if no values are found. It also means that if no values have been found yet, the line: If varMin <= varValues(i) Then evaluates to Null, and so the Else block executes. Since an If statement has three possible outcomes - True, False, and Null - a "do nothing" for one is a convenient way to handle the other two. If that is new, see Common errors with Null. Note that the functions would yield wrong results if the return value was not initialized to Null. VBA initializes it to Empty. In numeric comparisons, Empty is treated as zero. Since the function then has a zero already, it would then fail to identify the lowest number in the list.
AGE() FUNCTION Given a person's date-of-birth, how do you calculate their age? These examples do not work reliably: Format(Date() - DOB, "yyyy") DateDiff("y", DOB, Date) Int(DateDiff("d", DOB, Date)/365.25) DateDiff("y", ..., ...) merely subtracts the year parts of the dates, without reference to the month or day. This means we need to subtract one if the person has not has their birthday this year. The following expression returns True if the person has not had their birthday this year: DateSerial(Year(Date), Month(DOB), Day(DOB)) > Date True equates to -1, so by adding this expression, Access subtracts one if the birthday hasn't occurred. The function is therefore:
Function Age(varDOB As Variant, Optional varAsOf As Variant) As Variant 'Purpose: Return the Age in years. 'Arguments: varDOB = Date Of Birth ' varAsOf = the date to calculate the age at, or today if missing. 'Return: Whole number of years. Dim dtDOB As Date Dim dtAsOf As Date Dim dtBDay As Date 'Birthday in the year of calculation.
Age = Null 'Initialize to Null
'Validate parameters If IsDate(varDOB) Then dtDOB = varDOB
If Not IsDate(varAsOf) Then 'Date to calculate age from. dtAsOf = Date Else dtAsOf = varAsOf End If
If dtAsOf >= dtDOB Then 'Calculate only if it's after person was born. dtBDay = DateSerial(Year(dtAsOf), Month(dtDOB), Day(dtDOB)) Age = DateDiff("yyyy", dtDOB, dtAsOf) + (dtBDay > dtAsOf) End If End If End Function
Text2Clipboard(), Clipboard2Text() - 32-bit To collect data from an Access form for pasting to your your word processor, how about a double-click on the form's detail section? The code for the DblClick event will be something like this: Dim strOut as string strOut = Me.Title & " " & Me.FirstName & " " & Me.Surname & vbCrLf & _ Me.Address & vbCrLf & Me.City & " " & Me.State & " " & Me.Zip Text2Clipboard(strOut) Notes: This code will require modification if you use the 64-bit version of Office (not merely a 64-bit version of Windows.) Access 2007 and later support introduced Rich Text memo fields that contain embedded HTML tags. The Text2Clipboard() function copies the tags, and then the appear literally when you paste them. To avoid this situation, use the PlainText() function. In the example above, you would use: Text2Clipboard(PlainText(strOut))
32-bit Declarations (for Access 95 and later). (16-bit version also available for Access 1 and 2.) Declare Function abOpenClipboard Lib "User32" Alias "OpenClipboard" (ByVal Hwnd As Long) As Long Declare Function abCloseClipboard Lib "User32" Alias "CloseClipboard" () As Long Declare Function abEmptyClipboard Lib "User32" Alias "EmptyClipboard" () As Long Declare Function abIsClipboardFormatAvailable Lib "User32" Alias "IsClipboardFormatAvailable" (ByVal wFormat As Long) As Long Declare Function abSetClipboardData Lib "User32" Alias "SetClipboardData" (ByVal wFormat As Long, ByVal hMem As Long) As Long Declare Function abGetClipboardData Lib "User32" Alias "GetClipboardData" (ByVal wFormat As Long) As Long Declare Function abGlobalAlloc Lib "Kernel32" Alias "GlobalAlloc" (ByVal wFlags As Long, ByVal dwBytes As Long) As Long Declare Function abGlobalLock Lib "Kernel32" Alias "GlobalLock" (ByVal hMem As Long) As Long Declare Function abGlobalUnlock Lib "Kernel32" Alias "GlobalUnlock" (ByVal hMem As Long) As Boolean Declare Function abLstrcpy Lib "Kernel32" Alias "lstrcpyA" (ByVal lpString1 As Any, ByVal lpString2 As Any) As Long Declare Function abGlobalFree Lib "Kernel32" Alias "GlobalFree" (ByVal hMem As Long) As Long Declare Function abGlobalSize Lib "Kernel32" Alias "GlobalSize" (ByVal hMem As Long) As Long Const GHND = &H42 Const CF_TEXT = 1 Const APINULL = 0 To copy to the clipboard: Function Text2Clipboard(szText As String) Dim wLen As Integer Dim hMemory As Long Dim lpMemory As Long Dim retval As Variant Dim wFreeMemory As Boolean
' Get the length, including one extra for a CHR$(0) at the end. wLen = Len(szText) + 1 szText = szText & Chr$(0) hMemory = abGlobalAlloc(GHND, wLen + 1) If hMemory = APINULL Then MsgBox "Unable to allocate memory." Exit Function End If wFreeMemory = True lpMemory = abGlobalLock(hMemory) If lpMemory = APINULL Then MsgBox "Unable to lock memory." GoTo T2CB_Free End If
If abOpenClipboard(0&) = APINULL Then MsgBox "Unable to open Clipboard. Perhaps some other application is using it." GoTo T2CB_Free End If If abEmptyClipboard() = APINULL Then MsgBox "Unable to empty the clipboard." GoTo T2CB_Close End If If abSetClipboardData(CF_TEXT, hMemory) = APINULL Then MsgBox "Unable to set the clipboard data." GoTo T2CB_Close End If wFreeMemory = False
T2CB_Close: If abCloseClipboard() = APINULL Then MsgBox "Unable to close the Clipboard." End If If wFreeMemory Then GoTo T2CB_Free Exit Function
T2CB_Free: If abGlobalFree(hMemory) <> APINULL Then MsgBox "Unable to free global memory." End If End Function To paste from the clipboard: Function Clipboard2Text() Dim wLen As Integer Dim hMemory As Long Dim hMyMemory As Long
Dim lpMemory As Long Dim lpMyMemory As Long
Dim retval As Variant Dim wFreeMemory As Boolean Dim wClipAvail As Integer Dim szText As String Dim wSize As Long
If abIsClipboardFormatAvailable(CF_TEXT) = APINULL Then Clipboard2Text = Null Exit Function End If
If abOpenClipboard(0&) = APINULL Then MsgBox "Unable to open Clipboard. Perhaps some other application is using it." GoTo CB2T_Free End If
hMemory = abGetClipboardData(CF_TEXT) If hMemory = APINULL Then MsgBox "Unable to retrieve text from the Clipboard." Exit Function End If wSize = abGlobalSize(hMemory) szText = Space(wSize)
wFreeMemory = True
lpMemory = abGlobalLock(hMemory) If lpMemory = APINULL Then MsgBox "Unable to lock clipboard memory." GoTo CB2T_Free End If
' Copy our string into the locked memory. retval = abLstrcpy(szText, lpMemory) ' Get rid of trailing stuff. szText = Trim(szText) ' Get rid of trailing 0. Clipboard2Text = Left(szText, Len(szText) - 1) wFreeMemory = False
CB2T_Close: If abCloseClipboard() = APINULL Then MsgBox "Unable to close the Clipboard." End If If wFreeMemory Then GoTo CB2T_Free Exit Function
CB2T_Free: If abGlobalFree(hMemory) <> APINULL Then MsgBox "Unable to free global clipboard memory." End If End Function
TABLEINFO() FUNCTION This function displays in the Immediate Window (Ctrl+G) the structure of any table in the current database. For Access 2000 or 2002, make sure you have a DAO reference. The Description property does not exist for fields that have no description, so a separate function handles that error. The code
Function TableInfo(strTableName As String) On Error GoTo TableInfoErr ' Purpose: Display the field names, types, sizes and descriptions for a table. ' Argument: Name of a table in the current database. Dim db As DAO.Database Dim tdf As DAO.TableDef Dim fld As DAO.Field
Set db = CurrentDb() Set tdf = db.TableDefs(strTableName) Debug.Print "FIELD NAME", "FIELD TYPE", "SIZE", "DESCRIPTION" Debug.Print "==========", "==========", "====", "==========="
For Each fld In tdf.Fields Debug.Print fld.Name, Debug.Print FieldTypeName(fld), Debug.Print fld.Size, Debug.Print GetDescrip(fld) Next Debug.Print "==========", "==========", "====", "==========="
TableInfoExit: Set db = Nothing Exit Function
TableInfoErr: Select Case Err Case 3265& 'Table name invalid MsgBox strTableName & " table doesn't exist" Case Else Debug.Print "TableInfo() Error " & Err & ": " & Error End Select Resume TableInfoExit End Function
Function GetDescrip(obj As Object) As String On Error Resume Next GetDescrip = obj.Properties("Description") End Function
Function FieldTypeName(fld As DAO.Field) As String 'Purpose: Converts the numeric results of DAO Field.Type to text. Dim strReturn As String 'Name to return
Select Case CLng(fld.Type) 'fld.Type is Integer, but constants are Long. Case dbBoolean: strReturn = "Yes/No" ' 1 Case dbByte: strReturn = "Byte" ' 2 Case dbInteger: strReturn = "Integer" ' 3 Case dbLong ' 4 If (fld.Attributes And dbAutoIncrField) = 0& Then strReturn = "Long Integer" Else strReturn = "AutoNumber" End If Case dbCurrency: strReturn = "Currency" ' 5 Case dbSingle: strReturn = "Single" ' 6 Case dbDouble: strReturn = "Double" ' 7 Case dbDate: strReturn = "Date/Time" ' 8 Case dbBinary: strReturn = "Binary" ' 9 (no interface) Case dbText '10 If (fld.Attributes And dbFixedField) = 0& Then strReturn = "Text" Else strReturn = "Text (fixed width)" '(no interface) End If Case dbLongBinary: strReturn = "OLE Object" '11 Case dbMemo '12 If (fld.Attributes And dbHyperlinkField) = 0& Then strReturn = "Memo" Else strReturn = "Hyperlink" End If Case dbGUID: strReturn = "GUID" '15
'Attached tables only: cannot create these in JET. Case dbBigInt: strReturn = "Big Integer" '16 Case dbVarBinary: strReturn = "VarBinary" '17 Case dbChar: strReturn = "Char" '18 Case dbNumeric: strReturn = "Numeric" '19 Case dbDecimal: strReturn = "Decimal" '20 Case dbFloat: strReturn = "Float" '21 Case dbTime: strReturn = "Time" '22 Case dbTimeStamp: strReturn = "Time Stamp" '23
'Constants for complex types don't work prior to Access 2007 and later. Case 101&: strReturn = "Attachment" 'dbAttachment Case 102&: strReturn = "Complex Byte" 'dbComplexByte Case 103&: strReturn = "Complex Integer" 'dbComplexInteger Case 104&: strReturn = "Complex Long" 'dbComplexLong Case 105&: strReturn = "Complex Single" 'dbComplexSingle Case 106&: strReturn = "Complex Double" 'dbComplexDouble Case 107&: strReturn = "Complex GUID" 'dbComplexGUID Case 108&: strReturn = "Complex Decimal" 'dbComplexDecimal Case 109&: strReturn = "Complex Text" 'dbComplexText Case Else: strReturn = "Field type " & fld.Type & " unknown" End Select
FieldTypeName = strReturn End Function
DIRLISTBOX() FUNCTION This article describes an old technique of filling a list box via a callback function. In Access 2000 and later, there is a newer technique that is more efficient and flexible. To use the callback function: Create a new module, by clicking the Modules tab of the Database window, and clicking New. Paste in the code below. Check that Access understands the code by choosing Compile on the Debug menu. Save the module with a name such as Module1. Set the Row Source Type property of your list box to just: DirListBox Do not use the equal sign or function brackets, and leave the Row Source property blank.
The code Function DirListBox (fld As Control, ID, row, col, code) ' Purpose: To read the contents of a directory into a ListBox. ' Usage: Create a ListBox. Set its RowSourceType to "DirListBox" ' Parameters: The arguments are provided by Access itself. ' Notes: You could read a FileSpec from an underlying form. ' Error handling not shown. More than 512 files not handled. Dim StrFileName As String Static StrFiles(0 To 511) As String ' Array to hold File Names Static IntCount As Integer ' Number of Files in list
Select Case code Case 0 ' Initialize DirListBox = True
Case 1 ' Open: load file names into array DirListBox = Timer StrFileName = Dir$("C:\") ' Read filespec from a form here??? Do While Len(StrFileName) > 0 StrFiles(IntCount) = StrFileName StrFileName = Dir IntCount = IntCount + 1 Loop
Case 3 ' Rows DirListBox = IntCount
Case 4 ' Columns DirListBox = 1
Case 5 ' Column width in twips DirListBox = 1440
Case 6 ' Supply data DirListBox = StrFiles(row)
End Select End Function
PLAYSOUND() FUNCTION To play a sound in any event, just set an event such as a form's OnOpen to: =PlaySound("C:\WINDOWS\CHIMES.WAV") Paste the declaration and function into a module, and save. Use the 16-bit version for Access 1 and 2. Note that these calls will not work with the 64-bit version of Office (as distinct from the 64-bit versions of Windows.) 32-bit versions (Access 95 onwards): Declare Function apisndPlaySound Lib "winmm" Alias "sndPlaySoundA" _ (ByVal filename As String, ByVal snd_async As Long) As Long
Function PlaySound(sWavFile As String) ' Purpose: Plays a sound. ' Argument: the full path and file name.
If apisndPlaySound(sWavFile, 1) = 0 Then MsgBox "The Sound Did Not Play!" End If End Function
16-bit versions (Access 1 or 2): Declare Function sndplaysound% Lib "mmsystem" (ByVal filename$, ByVal snd_async%)
Function PlaySound (msound) Dim XX% XX% = sndplaysound(msound, 1) If XX% = 0 Then MsgBox "The Sound Did Not Play!" End Function
PARSEWORD() FUNCTION This function parses a word or item from a field or expression. It is similar to the built-in Split() function, but extends its functionality to handle nulls, errors, finding the last item, removing leading or doubled spacing, and so on. It is particularly useful for importing data where expressions need to be split into different fields. Use your own error logger, or copy the one in this link: LogError() Examples To get the second word from "My dog has fleas": ParseWord("My dog has fleas", 2) To get the last word from the FullName field: ParseWord([FullName], -1) To get the second item from a list separated by semicolons: ParseWord("first;second;third;fourth;fifth", 2, ";") To get the fourth sentence from the Notes field: ParseWord([Notes], 4, ".") To get the third word from the Address field, ignoring any doubled up spaces in the field: ParseWord([Address], 3, ,True, True) Arguments varPhrase: the field or expression that contains the word you want. iWordNum: which word: 1 for the first word, 2 for the second, etc. Use -1 to get the last word, -2 for the second last, ... strDelimiter: the character that separates the words. Assumed to be a space unless you specify otherwise. bRemoveLeavingDelimiters: If True, any leading spaces are removed from the phrase before processing. Defaults to False. bIgnoreDoubleDelimiters: If True, any double-spaces inside the phrase are treated as a single space. Defaults to False. Return The word from the string if found. Null for other cases, including the second word in this string, "Two spaces", unless the last argument is True. The code
Function ParseWord(varPhrase As Variant, ByVal iWordNum As Integer, Optional strDelimiter As String = " ", _ Optional bRemoveLeadingDelimiters As Boolean, Optional bIgnoreDoubleDelimiters As Boolean) As Variant On Error GoTo Err_Handler 'Purpose: Return the iWordNum-th word from a phrase. 'Return: The word, or Null if not found. 'Arguments: varPhrase = the phrase to search. ' iWordNum = 1 for first word, 2 for second, ... ' Negative values for words form the right: -1 = last word; -2 = second last word, ... ' (Entire phrase returned if iWordNum is zero.) ' strDelimiter = the separator between words. Defaults to a space. ' bRemoveLeadingDelimiters: If True, leading delimiters are stripped. ' Otherwise the first word is returned as null. ' bIgnoreDoubleDelimiters: If true, double-spaces are treated as one space. ' Otherwise the word between spaces is returned as null. 'Author: Allen Browne. http://allenbrowne.com. June 2006. Dim varArray As Variant 'The phrase is parsed into a variant array. Dim strPhrase As String 'varPhrase converted to a string. Dim strResult As String 'The result to be returned. Dim lngLen As Long 'Length of the string. Dim lngLenDelimiter As Long 'Length of the delimiter. Dim bCancel As Boolean 'Flag to cancel this operation.
'************************************* 'Validate the arguments '************************************* 'Cancel if the phrase (a variant) is error, null, or a zero-length string. If IsError(varPhrase) Then bCancel = True Else strPhrase = Nz(varPhrase, vbNullString) If strPhrase = vbNullString Then bCancel = True End If End If 'If word number is zero, return the whole thing and quit processing. If iWordNum = 0 And Not bCancel Then strResult = strPhrase bCancel = True End If 'Delimiter cannot be zero-length. If Not bCancel Then lngLenDelimiter = Len(strDelimiter) If lngLenDelimiter = 0& Then bCancel = True End If End If
'************************************* 'Process the string '************************************* If Not bCancel Then strPhrase = varPhrase 'Remove leading delimiters? If bRemoveLeadingDelimiters Then strPhrase = Nz(varPhrase, vbNullString) Do While Left$(strPhrase, lngLenDelimiter) = strDelimiter strPhrase = Mid(strPhrase, lngLenDelimiter + 1&) Loop End If 'Ignore doubled-up delimiters? If bIgnoreDoubleDelimiters Then Do lngLen = Len(strPhrase) strPhrase = Replace(strPhrase, strDelimiter & strDelimiter, strDelimiter) Loop Until Len(strPhrase) = lngLen End If 'Cancel if there's no phrase left to work with If Len(strPhrase) = 0& Then bCancel = True End If End If
'************************************* 'Parse the word from the string. '************************************* If Not bCancel Then varArray = Split(strPhrase, strDelimiter) If UBound(varArray) >= 0 Then If iWordNum > 0 Then 'Positive: count words from the left. iWordNum = iWordNum - 1 'Adjust for zero-based array. If iWordNum <= UBound(varArray) Then strResult = varArray(iWordNum) End If Else 'Negative: count words from the right. iWordNum = UBound(varArray) + iWordNum + 1 If iWordNum >= 0 Then strResult = varArray(iWordNum) End If End If End If End If
'************************************* 'Return the result, or a null if it is a zero-length string. '************************************* If strResult <> vbNullString Then ParseWord = strResult Else ParseWord = Null End If
Exit_Handler: Exit Function
Err_Handler: Call LogError(Err.Number, Err.Description, "ParseWord()") Resume Exit_Handler End Function
How it works The function accepts a Variant as the phrase, so you can use it where a field could be null (a field with no value) or error (e.g. trying to parse a field on a report that has no records.) The first stage is to validate the arguments before trying to use them. The second stage is to pre-process the string to remove leading delimiters, or to ignore doubled-up delimiters within the string, if the optional arguments indicate the user wants this. The Split() function parses the phrase into an array of words. Since the array is zero-based, the word number is adjusted by 1. If the word number is negative, we count down from the upper bound of the array. Note that iWordNum is passed ByVal since we are changing its value within the procedure. Finally we return the result string, or Null if the result is a zero-length string.
FILEEXISTS() AND FOLDEREXISTS() FUNCTIONS Use these functions to determine whether a file or directory is accessible. They are effectively wrappers for Dir() and GetAttr() respectively. Searching an invalid network name can be slow. FileExists() This function returns True if there is a file with the name you pass in, even if it is a hidden or system file. Assumes the current directory if you do not include a path. Returns False if the file name is a folder, unless you pass True for the second argument. Returns False for any error, e.g. invalid file name, permission denied, server not found. Does not search subdirectories. To enumerate files in subfolders, see List files recursively. FolderExists() This function returns True if the string you supply is a directory. Return False for any error: server down, invalid file name, permission denied, and so on. TrailingSlash() Use the TrailingSlash() function to add a slash to the end of a path unless it is already there. Examples Look for a file named MyFile.mdb in the Data folder: FileExists("C:\Data\MyFile.mdb") Look for a folder named System in the Windows folder on C: drive: FolderExists("C:\Windows\System") Look for a file named MyFile.txt on a network server: FileExists("\\MyServer\MyPath\MyFile.txt") Check for a file or folder name Wotsit on the server: FileExists("\\MyServer\Wotsit", True) Check the folder of the current database for a file named GetThis.xls: FileExists(TrailingSlash(CurrentProject.Path) & "GetThis.xls") The code
Function FileExists(ByVal strFile As String, Optional bFindFolders As Boolean) As Boolean 'Purpose: Return True if the file exists, even if it is hidden. 'Arguments: strFile: File name to look for. Current directory searched if no path included. ' bFindFolders. If strFile is a folder, FileExists() returns False unless this argument is True. 'Note: Does not look inside subdirectories for the file. 'Author: Allen Browne. http://allenbrowne.com June, 2006. Dim lngAttributes As Long
'Include read-only files, hidden files, system files. lngAttributes = (vbReadOnly Or vbHidden Or vbSystem)
If bFindFolders Then lngAttributes = (lngAttributes Or vbDirectory) 'Include folders as well. Else 'Strip any trailing slash, so Dir does not look inside the folder. Do While Right$(strFile, 1) = "\" strFile = Left$(strFile, Len(strFile) - 1) Loop End If
'If Dir() returns something, the file exists. On Error Resume Next FileExists = (Len(Dir(strFile, lngAttributes)) > 0) End Function
Function FolderExists(strPath As String) As Boolean On Error Resume Next FolderExists = ((GetAttr(strPath) And vbDirectory) = vbDirectory) End Function
Function TrailingSlash(varIn As Variant) As String If Len(varIn) > 0 Then If Right(varIn, 1) = "\" Then TrailingSlash = varIn Else TrailingSlash = varIn & "\" End If End If End Function
CLEARLIST() AND SELECTALL() FUNCTIONS The ClearList() function deselects all items in a multi-select list box, or sets the value to Null if the list box is not multi-select. The SelectAll() function selects all the items in a multi-select list box. It has no effect is the list box is not multi-select. Use your own error logger, or copy the one in this link: LogError() Examples: To select all items in the list box named List0 on Form1: Call SelectAll(Forms!Form1!List0) To deselect them all: Call ClearList(Forms!Form1!List0) The code
Function ClearList(lst As ListBox) As Boolean On Error GoTo Err_ClearList 'Purpose: Unselect all items in the listbox. 'Return: True if successful 'Author: Allen Browne. http://allenbrowne.com June, 2006. Dim varItem As Variant
If lst.MultiSelect = 0 Then lst = Null Else For Each varItem In lst.ItemsSelected lst.Selected(varItem) = False Next End If
ClearList = True
Exit_ClearList: Exit Function
Err_ClearList: Call LogError(Err.Number, Err.Description, "ClearList()") Resume Exit_ClearList End Function
Public Function SelectAll(lst As ListBox) As Boolean On Error GoTo Err_Handler 'Purpose: Select all items in the multi-select list box. 'Return: True if successful 'Author: Allen Browne. http://allenbrowne.com June, 2006. Dim lngRow As Long
If lst.MultiSelect Then For lngRow = 0 To lst.ListCount - 1 lst.Selected(lngRow) = True Next SelectAll = True End If
Exit_Handler: Exit Function
Err_Handler: Call LogError(Err.Number, Err.Description, "SelectAll()") Resume Exit_Handler End Function
COUNT LINES (VBA CODE) The code below returns the number of lines of code in the current database. It counts both the stand-alone modules and the modules behind forms and reports. Optionally, you can list the number of lines in each module, and/or give a summary of the number of each type of module and the line count for each type. To use the code in your database, create a new module, and paste it in. Then: Make sure your code is compiled (Compile on Debug menu) and saved (Save on File menu.) Open the Immediate Window (Ctrl+G), and enter: ? CountLines() January 2008 update: Added the optional lines to exclude the code in this module from the count.
Option Compare Database Option Explicit 'Purpose: Count the number of lines of code in your database. 'Author: Allen Browne (allen@allenbrowne.com) 'Release: 26 November 2007 'Copyright: None. You may use this and modify it for any database you write. ' All we ask is that you acknowledge the source (leave these comments in your code.) 'Documentation: http://allenbrowne.com/vba-CountLines.html
Public Function CountLines(Optional iVerboseLevel As Integer = 3) As Long On Error GoTo Err_Handler 'Purpose: Count the number of lines of code in modules of current database. 'Requires: Access 2000 or later. 'Argument: This number is a bit field, indicating what should print to the Immediate Window: ' 0 displays nothing ' 1 displays a summary for the module type (form, report, stand-alone.) ' 2 list the lines in each module ' 3 displays the summary and the list of modules. 'Notes: Code will error if dirty (i.e. the project is not compiled and saved.) ' Just click Ok if a form/report is assigned to a non-existent printer. ' Side effect: all modules behind forms and reports will be closed. ' Code window will flash, since modules cannot be opened hidden. Dim accObj As AccessObject 'Each module/form/report. Dim strDoc As String 'Name of each form/report Dim lngObjectCount As Long 'Number of modules/forms/reports Dim lngObjectTotal As Long 'Total number of objects. Dim lngLineCount As Long 'Number of lines for this object type. Dim lngLineTotal As Long 'Total number of lines for all object types. Dim bWasOpen As Boolean 'Flag to leave form/report open if it was open.
'Stand-alone modules. lngObjectCount = 0& lngLineCount = 0& For Each accObj In CurrentProject.AllModules 'OPTIONAL: TO EXCLUDE THE CODE IN THIS MODULE FROM THE COUNT: ' a) Uncomment the If ... and End If lines (3 lines later), by removing the single-quote. ' b) Replace MODULE_NAME with the name of the module you saved this in (e.g. "Module1") ' c) Check that the code compiles after your changes (Compile on Debug menu.) 'If accObj.Name <> "MODULE_NAME" Then lngObjectCount = lngObjectCount + 1& lngLineCount = lngLineCount + GetModuleLines(accObj.Name, True, iVerboseLevel) 'End If
Next lngLineTotal = lngLineTotal + lngLineCount lngObjectTotal = lngObjectTotal + lngObjectCount If (iVerboseLevel And micVerboseSummary) <> 0 Then Debug.Print lngLineCount & " line(s) in " & lngObjectCount & " stand-alone module(s)" Debug.Print End If
'Modules behind forms. lngObjectCount = 0& lngLineCount = 0& For Each accObj In CurrentProject.AllForms strDoc = accObj.Name bWasOpen = accObj.IsLoaded If Not bWasOpen Then DoCmd.OpenForm strDoc, acDesign, WindowMode:=acHidden End If If Forms(strDoc).HasModule Then lngObjectCount = lngObjectCount + 1& lngLineCount = lngLineCount + GetModuleLines("Form_" & strDoc, False, iVerboseLevel) End If If Not bWasOpen Then DoCmd.Close acForm, strDoc, acSaveNo End If Next lngLineTotal = lngLineTotal + lngLineCount lngObjectTotal = lngObjectTotal + lngObjectCount If (iVerboseLevel And micVerboseSummary) <> 0 Then Debug.Print lngLineCount & " line(s) in " & lngObjectCount & " module(s) behind forms" Debug.Print End If
'Modules behind reports. lngObjectCount = 0& lngLineCount = 0& For Each accObj In CurrentProject.AllReports strDoc = accObj.Name bWasOpen = accObj.IsLoaded If Not bWasOpen Then 'In Access 2000, remove the ", WindowMode:=acHidden" from the next line. DoCmd.OpenReport strDoc, acDesign, WindowMode:=acHidden End If If Reports(strDoc).HasModule Then lngObjectCount = lngObjectCount + 1& lngLineCount = lngLineCount + GetModuleLines("Report_" & strDoc, False, iVerboseLevel) End If If Not bWasOpen Then DoCmd.Close acReport, strDoc, acSaveNo End If Next lngLineTotal = lngLineTotal + lngLineCount lngObjectTotal = lngObjectTotal + lngObjectCount If (iVerboseLevel And micVerboseSummary) <> 0 Then Debug.Print lngLineCount & " line(s) in " & lngObjectCount & " module(s) behind reports" Debug.Print lngLineTotal & " line(s) in " & lngObjectTotal & " module(s)" End If
CountLines = lngLineTotal
Exit_Handler: Exit Function
Err_Handler: Select Case Err.Number Case 29068& 'This error actually occurs in GetModuleLines() MsgBox "Cannot complete operation." & vbCrLf & "Make sure code is compiled and saved." Case Else MsgBox "Error " & Err.Number & ": " & Err.Description End Select Resume Exit_Handler End Function
Private Function GetModuleLines(strModule As String, bIsStandAlone As Boolean, iVerboseLevel As Integer) As Long 'Usage: Called by CountLines(). 'Note: Do not use error handling: must pass error back to parent routine. Dim bWasOpen As Boolean 'Flag applies to standalone modules only.
If bIsStandAlone Then bWasOpen = CurrentProject.AllModules(strModule).IsLoaded End If If Not bWasOpen Then DoCmd.OpenModule strModule End If If (iVerboseLevel And micVerboseListAll) <> 0 Then Debug.Print Modules(strModule).CountOfLines, strModule End If GetModuleLines = Modules(strModule).CountOfLines If Not bWasOpen Then DoCmd.Close acModule, strModule, acSaveYes End If End Function
INSERT CHARACTERS AT THE CURSOR Copy the function below into a standard module in your database. You can then call it from anywhere in your database to insert characters into the active control, at the cursor position. If any characters are selected at the time you run this code, those characters are overwritten. That is in keeping with what normally happens in Windows programs. Usage examples Here are some examples of how the function could be used. 1. Simulate the tab character In Access, the Tab key does not insert a tab character as it does in Word. To simulate this, you could insert 4 spaces when the user presses the Tab key. Use the KeyDown event procedure of your text box, like this: Private Sub txtMemo_KeyDown(KeyCode As Integer, Shift As Integer) If (KeyCode = vbKeyTab) And (Shift = 0) Then If InsertAtCursor(" ") Then KeyCode = 0 End If End If End Sub
2. Insert boilerplate text This example inserts preset paragraphs at the cursor as the user presses Alt+1, Alt+2, etc. Private Sub Text0_KeyDown(KeyCode As Integer, Shift As Integer) Dim strMsg As String Dim strText As String
If Shift = acAltMask Then Select Case KeyCode Case vbKey1 strText = "Paragraph 1" & vbCrLf Case vbKey2 strText = "Paragraph 2" & vbCrLf Case vbKey3 strText = "Paragraph 3" & vbCrLf 'etc for other paragraphs. End Select If InsertAtCursor(strText, strMsg) Then KeyCode = 0 ElseIf strMsg <> vbNullString Then MsgBox strMsg, vbExclamation, "Problem inserting boilerplate text" End If End If End Sub
3. Toolbar button For a variation on the above, you could create a buttons on a custom toolbar/ribbon that insert the paragraphs. You could allow the user to define their own paragraphs (stored in a table), and use DLookup() to retrieve the values to insert. Note that you cannot use a command button on the form to do this: when its Click event runs, it has focus, and the attempt to insert text into the command button cannot succeed.
4. Insert today's date on a new line In a memo field, you may want to insert a new line and today's date when Alt+D is pressed Private Sub Text5_KeyDown(KeyCode As Integer, Shift As Integer) If (Shift = acAltMask) And KeyCode = vbKeyD Then Call InsertAtCursor(vbCrLf & Date) End If End Sub
The code Here is the code to copy into a standard module in your database: Public Function InsertAtCursor(strChars As String, Optional strErrMsg As String) As Boolean On Error GoTo Err_Handler 'Purpose: Insert the characters at the cursor in the active control. 'Return: True if characters were inserted. 'Arguments: strChars = the character(s) you want inserted at the cursor. ' strErrMsg = string to append any error messages to. 'Note: Control must have focus. Dim strPrior As String 'Text before the cursor. Dim strAfter As String 'Text after the cursor. Dim lngLen As Long 'Number of characters Dim iSelStart As Integer 'Where cursor is.
If strChars <> vbNullString Then With Screen.ActiveControl If .Enabled And Not .Locked Then lngLen = Len(.Text) 'SelStart can't cope with more than 32k characters. If lngLen <= 32767& - Len(strChars) Then 'Remember characters before cursor. iSelStart = .SelStart If iSelStart > 1 Then strPrior = Left$(.Text, iSelStart) End If 'Remember characters after selection. If iSelStart + .SelLength < lngLen Then strAfter = Mid$(.Text, iSelStart + .SelLength + 1) End If 'Assign prior characters, new ones, and later ones. .Value = strPrior & strChars & strAfter 'Put the cursor back where it as, after the new ones. .SelStart = iSelStart + Len(strChars) 'Return True on success InsertAtCursor = True End If End If End With End If
Exit_Handler: Exit Function
Err_Handler: Debug.Print Err.Number, Err.Description Select Case Err.Number Case 438&, 2135&, 2144& 'Object doesn't support this property. Property is read-only. Wrong data type. strErrMsg = strErrMsg & "You cannot insert text here." & vbCrLf Case 2474&, 2185& 'No active control. Control doesn't have focus. strErrMsg = strErrMsg & "Cannot determine which control to insert the characters into." & vbCrLf Case Else strErrMsg = strErrMsg & "Error " & Err.Number & ": " & Err.Description & vbCrLf End Select Resume Exit_Handler End Function
HYPERLINKS: WARNINGS, SPECIAL CHARACTERS, ERRORS The GoHyperlink() function (below) performs the same task as FollowHyperlink(), with improved control over the outcome. Like FollowHyperlink, you can use it to: Open a browser to a webpage (http:// prefix) Send an email (mailto: prefix) Open a file, using the program registered to handle that type (Word for .doc, Notepad for .txt, or Paint for .bmp, etc.) Why a replacement? FollowHyperlink can be frustrating: Security warnings may block you, or warn you not to open the file (depending on file type, location, Windows version, permissions, and policies.) Files fail to open if their names contains some characters (such as # or %.) Errors are generated if a link fails to open, so any routine that calls it must have similar error handling. GoHyperlink addresses those frustrations: It prepends "file:///" to avoid the most common security warnings. It handles special characters more intelligently. Errors are handled within the routine. Check the return value if you want to know if the link opened. It cannot solve these issues completely: If your network administrator will not allow hyperlinks to open at all, they will not open. If a file name contains two # characters, it will be understood as a hyperlink. Similarly, if a file name contains the % character followed by two valid hexadecimal digits (e.g. Studetn%50.txt), it will be be interpreted as a pre-escaped character rather than three literal characters. These are limitations relating to HTML. But you will experience these issues far less frequently than with FollowHyperlink, which fowls up whenever it finds one of these sequences. Using GoHyperlink() To use GoHyperlink() in your database: Create a new stand-alone module in your database. Open the code window (Ctrl+G), and the New Module button on the toolbar (2nd from left on Standard toolbar.) Paste in the code below. To verify Access understands it, choose Compile on the Debug menu. Save the module, with a name such as ajbHyperlink. You can now use GoHyperlink() anywhere in your database. For example if you have a form with a hyperlink field named MyHyperlink, use: Call GoHyperlink(Me.[MyHyperlink]) To open a file, be sure you pass in the full path. If necessary, use: Call GoHyperlink(CurDir & "\MyDoc.doc") The PrepareHyperlink() function can also be used to massage a file name so it will be handled correctly as a hyperlink. The code
Option Compare Database Option Explicit 'Purpose: Avoid warning and error messages when opening files with FollowHyperlink 'Author: Allen Browne (allen@allenbrowne.com) 'Release: 28 January 2008 'Usage: To open MyFile.doc in Word, use: ' GoHyperlink "MyFile.doc" ' instead of: ' FollowHyperlink "MyFile.doc" 'Rationale: 'FollowHyperlink has several problems: ' a) It errors if a file name contains characters such as #, %, or &. ' b) It can give unwanted warnings, e.g. on a fileame with "file:///" prefix. ' c) It yields errors if the link did not open. 'This replacement: ' a) escapes the problem characters ' b) prepends the prefix ' c) returns True if the link opened (with an optional error message if you care.) 'Limitations: ' - If a file name contains two # characters, it is treated as a hyperlink. ' - If a file name contains % followed by 2 hex digits, it assumes it is pre-escaped. ' - File name must include path. 'Documentation: http://allenbrowne.com/func-GoHyperlink.html
Public Function GoHyperlink(FullFilenameOrLink As Variant) As Boolean On Error GoTo Err_Handler 'Purpose: Replacement for FollowHyperlink. 'Return: True if the hyperlink opened. 'Argument: varIn = the link to open Dim strLink As String Dim strErrMsg As String
'Skip error, null, or zero-length string. If Not IsError(FullFilenameOrLink) Then If FullFilenameOrLink <> vbNullString Then strLink = PrepHyperlink(FullFilenameOrLink, strErrMsg) If strLink <> vbNullString Then FollowHyperlink strLink 'Return True if we got here without error. GoHyperlink = True End If 'Display any error message from preparing the link. If strErrMsg <> vbNullString Then MsgBox strErrMsg, vbExclamation, "PrepHyperlink()" End If End If End If
Exit_Handler: Exit Function
Err_Handler: MsgBox "Error " & Err.Number & ": " & Err.Description, vbExclamation, "GoHyperlink()" Resume Exit_Handler End Function Public Function PrepHyperlink(varIn As Variant, Optional strErrMsg As String) As Variant On Error GoTo Err_Handler 'Purpose: Avoid errors and warnings when opening hyperlinks. 'Return: The massaged link/file name. 'Arguments: varIn = the link/file name to massage. ' strErrMsg = string to append error messages to. 'Note: Called by GoHyperlink() above. ' Can also be called directly, to prepare hyperlinks. Dim strAddress As String 'File name or address Dim strDisplay As String 'Display part of hyperlink (if provided) Dim strTail As String 'Any remainding part of hyperlink after address Dim lngPos1 As Long 'Position of character in string (and next) Dim lngPos2 As Long Dim bIsHyperlink As Boolean 'Flag if input is a hyperlink (not just a file name.) Const strcDelimiter = "#" 'Delimiter character within hyperlinks. Const strcEscChar = "%" 'Escape character for hyperlinks. Const strcPrefix As String = "file:///" 'Hyperlink type if not supplied.
If Not IsError(varIn) Then strAddress = Nz(varIn, vbNullString) End If
If strAddress <> vbNullString Then 'Treat as a hyperlink if there are two or more # characters (other than together, or at the end.) lngPos1 = InStr(strAddress, strcDelimiter) If (lngPos1 > 0&) And (lngPos1 < Len(strAddress) - 2&) Then lngPos2 = InStr(lngPos1 + 1&, strAddress, strcDelimiter) End If If lngPos2 > lngPos1 + 1& Then bIsHyperlink = True strTail = Mid$(strAddress, lngPos2 + 1&) strDisplay = Left$(strAddress, lngPos1 - 1&) strAddress = Mid$(strAddress, lngPos1 + 1&, lngPos2 - lngPos1) End If
'Replace any % that is not immediately followed by 2 hex digits (in both display and address.) strAddress = EscChar(strAddress, strcEscChar) strDisplay = EscChar(strDisplay, strcEscChar) 'Replace special characters with percent sign and hex value (address only.) strAddress = EscHex(strAddress, strcEscChar, "&", """", " ", "#", "<", ">", "|", "*", "?") 'Replace backslash with forward slash (address only.) strAddress = Replace(strAddress, "\", "/") 'Add prefix if address doesn't have one. If Not ((varIn Like "*://*") Or (varIn Like "mailto:*")) Then strAddress = strcPrefix & strAddress End If End If
'Assign return value. If strAddress <> vbNullString Then If bIsHyperlink Then PrepHyperlink = strDisplay & strcDelimiter & strAddress & strcDelimiter & strTail Else PrepHyperlink = strAddress End If Else PrepHyperlink = Null End If
Exit_Handler: Exit Function
Err_Handler: strErrMsg = strErrMsg & "Error " & Err.Number & ": " & Err.Description & vbCrLf Resume Exit_Handler End Function
Private Function EscChar(ByVal strIn As String, strEscChar As String) As String 'Purpose: If the escape character is found in the string, ' escape it (unless it is followed by 2 hex digits.) 'Return: Fixed up string. 'Arguments: strIn = the string to fix up ' strEscChar = the single character used for escape sequqnces. (% for hyperlinks.) Dim strOut As String 'output string. Dim strChar As String 'character being considered. Dim strTestHex As String '4-character string of the form &HFF. Dim lngLen As Long 'Length of input string. Dim i As Long 'Loop controller Dim bReplace As Boolean 'Flag to replace character.
lngLen = Len(strIn) If (lngLen > 0&) And (Len(strEscChar) = 1&) Then For i = 1& To lngLen bReplace = False strChar = Mid(strIn, i, 1&) If strChar = strEscChar Then strTestHex = "&H" & Mid(strIn, i + 1&, 2&) If Len(strTestHex) = 4& Then If Not IsNumeric(strTestHex) Then bReplace = True End If End If End If If bReplace Then strOut = strOut & strEscChar & Hex(Asc(strEscChar)) Else strOut = strOut & strChar End If Next End If
If strOut <> vbNullString Then EscChar = strOut ElseIf lngLen > 0& Then EscChar = strIn End If End Function
Private Function EscHex(ByVal strIn As String, strEscChar As String, ParamArray varChars()) As String 'Purpose: Replace any characters from the array with the escape character and their hex value. 'Return: Fixed up string. 'Arguments: strIn = string to fix up. ' strEscChar = the single character used for escape sequqnces. (% for hyperlinks.) ' varChars() = an array of single-character strings to replace. Dim i As Long 'Loop controller
If (strIn <> vbNullString) And IsArray(varChars) Then For i = LBound(varChars) To UBound(varChars) strIn = Replace(strIn, varChars(i), strEscChar & Hex(Asc(varChars(i)))) Next End If EscHex = strIn End Function
INTELLIGENT HANDLING OF DATES AT THE START OF A CALENDAR YEAR Did you know 80% of this year's dates can be entered with 4 keystrokes or less? Jan 1 is just 1/1 (or 1 1). Access automatically supplies the current year. Good data entry operators regularly enter dates like this. But this comes unstuck during the first quarter of a new year, when you are entering dates from the last quarter of last year. It is January, and you type 12/12. Access interprets it as 11 months in the future, when it is much more likely to be the month just gone. The code below changes that, so if you enter a date from the final calendar quarter but do not specify a year, it is interpreted as last year. But it does so only if today is in the first quarter of a new year. How to use To use this in your database: Copy the function: In your database, open the code window (e.g. press Ctrl+G.) On the Insert menu, choose Module. Access opens a new module. Paste in the code below. To ensure Access understands it, choose Compile on the Debug menu. Save the module with a name such as ajbAdjustDateForYear. Apply to a text box: Open your form in design view. Right-click the text box and choose Properties. In the Properties box, set After Update to: =AdjustDateForYear([Text0]) substituting your text box name for Text0. Repeat step 2 for other your text boxes. If the After Update property of your text box is already set to: [Event Procedure] click the Build button (...) beside this property. Access opens the code window. In the AfterUpdate procedure, insert this line (substituting your text box name for Text0): Call AdjustDateForYear(Me.Text0) Optional: If you want to warn the user when an entry will be adjusted, set bConfirm to True instead of False. As offered, no warning is given, as the goal is to speed up good data entry operators. The way it behaves is analogous to the way Access handles dates when the century is not specified. Limitations As supplied, the code works only with text boxes (not combos), and only in countries where the date delimiter is slash (/) or dash (-). Other delimiter characters such as dot (.) are not handled. The code makes no changes if you enter a time as well as a date. For unbound text boxes, the code does nothing if it does not recognize your entry as a date. Setting the Format property of the unbound text box to General Date can help Access understand that you intend a date. The function
Public Function AdjustDateForYear(txt As TextBox, Optional bConfirm As Boolean = False) As Boolean On Error GoTo Err_Handler 'Purpose: Adjust the text box value for change of year. ' If the user entered Oct-Dec *without* a year, and it's now Jan-Mar, _ Access will think it's this year when it's probably last year. 'Arguments: txt: the text box to examine. ' bConfirm: set this to True if you want a confirmation dialog. 'Return: True if the value was changed. 'Usage: For a text box named Text0, set it's After Update property to: ' =AdjustDateForYear([Text0]) ' Or in code use: ' Call AdjustDateForYear(Me.Text0) 'Note: Makes no chanage if the user specifies a year, or includes a time. Dim dt As Date 'Value of the text box Dim strText As String 'The Text property of the text box. Dim lngLen As Long 'Length of string. Dim bSuppress As Boolean 'Flag to suppress the change (user answered No.) Const strcDateDelim = "/" 'Delimiter character for dates.
With txt 'Only if the value is Oct/Nov/Dec, today is Jan/Feb/Mar, and the year is the same. If IsDate(.Value) Then dt = .Value If (Month(dt) >= 10) And (Month(Date) <= 3) And (Year(dt) = Year(Date)) Then 'Get the Text in the text box, without leading/trailing spaces, _ and change dash to the date delimiter. strText = Replace$(Trim$(.Text), "-", strcDateDelim)
'Change multiple spaces to one, then to the date delimiter. Do lngLen = Len(strText) strText = Replace$(strText, " ", " ") Loop Until Len(strText) = lngLen strText = Replace$(strText, " ", strcDateDelim)
'Subtract a year if only ONE delimiter appears in the Text (i.e. no year.) If Len(strText) - Len(Replace$(strText, strcDateDelim, vbNullString)) = 1& Then dt = DateAdd("yyyy", -1, dt) If bConfirm Then strText = "Did you intend:" & vbCrLf & vbTab & Format$(dt, "General Date") If MsgBox(strText, vbYesNo, "Adjust date for year?") = vbNo Then bSuppress = True End If End If If Not bSuppress Then .Value = dt End If AdjustDateForYear = True End If End If End If End With
Exit_Handler: Exit Function
Err_Handler: If Err.Number <> 2185& Then 'Text box doesn't have focus, so no Text property. MsgBox "Error " & Err.Number & ": " & Err.Description, vbExclamation, "AdjustDateForYear" 'Call LogError(Err.Number, Err.Description, ".AdjustDateForYear") End If Resume Exit_Handler End Function
How it works We only make a change if: the text box contains a date (so is not null) today is in the first quarter of the year (i.e. the Month() of the Date is 3 or less) the value of the text box is October or later of the current year the user did not specify a year. The first two IF statements deal with (a), (b), and (c), but (d) requires a bit more effort. As well as its Value property, a text box has a Text property that exposes the actual characters in the box. The Text property will contain only one delimiter (/) if there is no year. In practice, Access lets you use the slash (/), dash (-), space, or even multiple spaces as a delimiter between the different parts of the date. The code therefore strips multiple spaces back to one, and substitutes the slash for any dash or space. It then examines the length of the text, compared to the length of the text if you remove the delimiters. If the difference is 1, the user entered only one delimiter, so they did not specify a year. We have now evaluated (d). If the bConfirm argument tells us to give a warning, we pop up the MsgBox() to get confirmation. Finally, if all these conditions are met, we assign a Value to the text box that is one year less, and return True to indicate a change was made. The error handler silently suppresses error 2185. If the code runs when another control has focus on its form, the attempt to read the Text property will fail. Normally this could not happen: a control's AfterUpdate event cannot fire unless it has focus. But it could occur if you programmatically call its AfterUpdate event procedure. The alternative error handler line is provided (commented out) in case you want to use our error logger.
Keep something open To prevent users modifying the database schema, developers normally hide the Navigation Pane/Database Window, and use a form as the interface to everything in the application. We will refer to this form as the "switchboard", whether you created it yourself or via the wizard. When the user closes everything else, we want the switchboard to open automatically. The code below does that. Just set one property for each form and report. Note that the code does not run when if the user closes the switchboard itself. If you try that, closing the database is a nightmare (worse in some versions than others.) Every time you close the switchboard, you open it again, so you can't get out. So, provided the switchboard is not the last thing closed, the code below will open it. To use this in your database: In your database, open the code window (Ctrl+G.) Insert a new standard module (Module on the Insert menu.) Paste the code below into this window. Replace the word frmSwitchboard with the name of your switchboard form (about a dozen lines from the bottom.) To verify that Access understands the code, choose Compile on the Debug menu. Save the module with a name such as ajbKeep1Open. For each form in your database (except the switchboard), set its On Close property to: =Keep1Open([Form]) For each report in your database, set its On Close property to: =Keep1Open([Report]) Notes: Don't substitute the name of your form or report above. Literally type [Form] or [Report] including the square brackets. You don't need to set the Close property for subforms or subreports. If the On Close property is set to [Event Procedure], click the Build button (...) beside the property. Access opens the code window. In the close event, add the line: Call Keep1Open(Me) Grab the error logger code if you wish to use that line in the error handler, rather than the MsgBox. The code is not designed to distinguish multiple instances of the same form. Test the hWnd as well if you need to handle that. Do NOT set the Close property for your switchboard. If it bothers you that the switchboard does not reopen itself every time you close it, you could create a macro named AutoKeys, and define a hotkey. The example below opens a form named frmSwitchboard when you press F12 anywhere in the database.
The code
Option Compare Database Option Explicit Public Function Keep1Open(objMe As Object) On Error GoTo Err_Keep1Open 'Purpose: Open the Switchboard if nothing else is visible. 'Argument: The object being closed. 'Usage: In the OnClose property of forms and reports: ' =Keep1Open([Form]) ' =Keep1Open([Report]) 'Note: Replace "Switchboard" with the name of your switchboard form. Dim frm As Form 'an open form. Dim rpt As Report 'an open report. Dim bFound As Boolean 'Flag not to open the switchboard.
'Any other visible forms? If Not bFound Then For Each frm In Forms If (frm.hWnd <> objMe.hWnd) And (frm.Visible) Then bFound = True Exit For End If Next End If
'Any other visible reports? If Not bFound Then For Each rpt In Reports If (rpt.hWnd <> objMe.hWnd) And (rpt.Visible) Then bFound = True Exit For End If Next End If
'If none found, open the switchboard. If Not bFound Then DoCmd.OpenForm "Switchboard" End If
Exit_Keep1Open: Set frm = Nothing Set rpt = Nothing Exit Function
Err_Keep1Open: If Err.Number <> 2046& Then 'OpenForm is not available when closing database. 'Call LogError(Err.Number, Err.Description, ".Keep1Open()") MsgBox "Error " & Err.Number & ": " & Err.Description, vbExclamation, "Keep1Open()" End If End Function
How it works You can use the code without learning how it works, as the only change you need to make is to substitute the name of your switchboard form. Normally, you should use the narrowest data type you can: Form rather than Object, Textbox rather than Control, etc. This function accepts an Object, so we can use it with both forms and reports. The Forms collection lists the open forms, so we loop through this list. As soon as we find an open form that is visible and is not ObjMe (the form being closed), we set the bFound flag to true, and skip the rest of the forms (as we know we don't need to open the switchboard.) The form that called Keep1Open() will be in the Forms collection, but we want to ignore it and see if any other visible forms are open. You may be tempted to use: If frm.Name <> obj.Name Then But examining the Name is not good enough. It fails if: A form and a report have the same name, or Multiple instances of the same form are open (since they have the same name.) We could have used: If Not frm Is objMe Then but old versions of Access (97) do not always handle Is correctly. The safest solution is to test the hWnd property. This is a unique number assigned by Windows so it can manage the form. Since no two windows can have the same hWnd at the same time, we completely avoid the issue of duplicate names. If we did not find any other visible form open, we do exactly the same thing with the Reports collection, so see if any other visible report is open. Finally, if no other visible form or report was found, we open the switchboard. The error handler suppresses error 2046, which can occur if it tries to open the switchboard when you are trying to close the database. (The ampersand is a type declaration character, indicating the literal 2046 is a Long.)
SPLASH SCREEN WITH VERSION INFORMATION Note: This code will not work with the 64-bit version of Office. (It does work with 64-bit Windows.) To showcase your database, you want a nifty screen that splashes color at start-up. You need this screen to give version details also, so you can show it through About this Program(typically on the Help menu.) The screen will show the software name, intellectual property rights, and your contact details just in case someone needs support. When they do call for support, it can help if this screen shows their Access setup and version details. It splashes on screen for 2 seconds when your database loads. If you open it from a button or menu, click on the form to close it. Download the sample database (zipped) for Access 2000 and later or Access 97. You can also view the code for this utility.
Click an element for details Copy to your database Open your database, and import: module ajbVersion, form frmHelpAbout, and macro AutoExec. In Access 97 - 2003, use Import on the File menu. In Access 2007 and 2010, the External Data tab of the ribbon handles imports. Open the module in design view to verify Access understands it. In the code window, choose Compile on the Debug menu. (In Access 2000 or 2002, you may need to set references.) When the splash screen closes initially, it opens another one. If your next form is not named Switchboard, change the name in the code. For example, change the line: Const strcNextForm = "Switchboard" to: Const strcNextForm = "Form1" If you do not want another screen to open, use: Const strcNextForm = "" Open the form in design view. If you have attached tables, replace "Test1" with the name of your table. Otherwise use: =GetDataPath("") Change the Caption of the labels to show your software name, copyright, and developer details. (Optional.) Set the form's Picture property to whatever you want.
Why show these details? This table summarizes the information displayed on the splash screen, and why you want to show these details: Caption Control Source Description Purpose Version: ="1.00" Whatever you want. Indicates if the user has your latest version. MS Access: =GetAccessVersion() Version of msaccess.exe Indicates if Office service packs are needed. File Format: =GetFileFormat() db format, e.g. 97, 2002/3, accdb Helps identify version-specific problems. JET/ACE: =GetJetVersion() Version of the query engine Indicates if JET service packs are needed. JET User: =CurrentUser() User name (Access security) Helps identify problems with Access Permissions. Win User: =GetNetworkUserName() User name (Windows) Helps identify problems with Windows permissions. Useful for logging. Workstation: =GetMachineName() Computer name Helps identify corruptions from faulty hardware. Useful for logging. Data File: =GetDataPath("Table1") Location of back end database Indicates if the front end is connected to the right data file. Version and Data File are the only ones you need to change. Let's see what each one tells you. Your Version This is a number you manually increment each time you modify the database, and distribute a version to your users. GetAccessVersion() MS Access Service Pack Version 97 SR-2 8.0.0.5903 2000 SP-3 9.0.0.6620 2002 SP-3 10.0.6501.0 2003 SP-3 11.0.8166.0 2007 SP-3 12.0.6607.1000 2010 - 14.0.4760.1000 MS Access Version This number indicates the version of msaccess.exe. The major number (e.g. 12.0) indicates the office version. The minor number (e.g. 6423.1000) indicates what service pack has been applied. The number may be higher than shown at right if you apply a hotfix, such asService Pack 2 for Access 2007, or kb945674 for Access 2003. Service packs are available from http://support.microsoft.com/sp. Office 97 is no longer supported, but you may get the patch here. (Note: These are the version numbers of msaccess.exe, not the Office numbers shown under Help | About. The Access 2010 numbers seem unstable at release time.) File Format GetFileFormat() Access 97 Access 2000 Access 2002 Access 2003 2007 & 2010 97 MDB 2000 MDB 2000 MDB 2000 MDB 2000 MDB 97 MDE 2000 MDE 2000 ADP 2000 ADE 2000 MDE 2000 ADP 2000 ADE 2002/3 MDB 2002/3 MDE 2002/3 ADP 2002/3 ADE 2000 MDE 2000 ADP 2000 ADE 2002/3 MDB 2002/3 MDE 2002/3 ADP 2002/3 ADE 2000 MDE 2000 ADP 2000 ADE 2002/3 MDB 2002/3 MDE 2002/3 ADP 2002/3 ADE 2007 ACCDB 2007 ACCDE 2007 ACCDR 2007 ACCDT This text box indicates what file format your database is using. If the database is split, it refers to the front end. Access 97 has two possible formats: MDB (Microsoft Database), MDE (compiled-only database.) Access 2000 uses a different format MDB and MDE, and added: ADP (Access Database Project, using SQL Server tables), ADE (compiled-only project.) Access 2002 introduced its own file storage format, but supports the Access 2000 ones as well. Access 2003 used the 2002 format (now called 2002/3), retaining support for the 2000 formats. Access 2007 and 2010 support all eight 2000 and 2002/3 formats, plus four new ones: ACCDB (database based on the new ACE engine), and ACCDE (compiled-only ACE database.) ACCDR (ACCDB or ACCDE limited to runtime) ACCDT (database template) Note: Even though Access 2010 uses the 2007 ACCD* file format, you will no longer be able to use the tables in Access 2007 if you add calculated fields to them. (Note: descriptions may not be correct for versions beyond Access 2010.) GetJetVersion() JET/ACE Version JET (Joint Engine Technology) is the data engine Access uses for its tables and queries. Different versions of Access use different versions of JET, and Microsoft supplies the JET service packs for JET separately from the Office service packs. Access 97 uses JET 3.5 (msjet35.dll). A fully patched version of Access 97 should show version 3.51.3328.0. Microsoft no longer supports Access 97, so it can be difficult to get service packs. Access 2000, 2002 and 2003 use JET 4 (msjet40.dll.) They should show at least 4.0.8618.0. The minor version may start with 9 (depending on your version of Windows), but if it is less than 8, it is crucial to download SP8 for JET 4 from http://support.microsoft.com/kb/239114. The issue is not only that older versions have unfixed bugs, but that you are likely tocorrupt a database if computers with different versions of JET use it at the same time. Access 2007 uses a private version of JET call the the Access Data Engine (acecore.dll), with a major version of 12. Since this version is private to Office, we expect it to be maintained by the Office 2007 service packs, and not require separate maintenance. JET User This displays the name the user logged into the database with. If you are not using Access security, it will be the default user, Admin. If you have secured the database, knowing the user name may help you track down problems related to limited user permissions. The CurrentUser() function is built into Access, so no API call is needed. GetNetworkUserName() Windows User This displays the Windows user name (see User Accounts in the Windows Control Panel.) It can help in tracing a problem related to the user's limited permissions under Windows. You can also call GetNetworkUserName() in your database to log user activity. We use the API call, as it is possible to fudge the value of Environ("username"). GetMachineName() Workstation This displays the name of the computer, as shown on the network. Corruption of the database is usually associated with the interrupted write (see Preventing corruption), so logging users in and out of the database with GetMachineName() can help to identify the machine that is crashing and corrupting the database. GetDataPath() Data File Use this with a split database, to indicate what file this front end is attached to. Occasionally you may get users who attached to the wrong database (such as a backup.) Specify the name of an attached table in place of "Table1." If you do not have an attached table matching the name you used, you see #Error. To suppress this option if you have no attached tables, use a zero-length string, i.e.: =GetDataPath("") Note that the screen reports what data file is expected, whether found or not. For example, the sample database has a table named Test1 that it expects to find in C:\Data\junk.mdb. You probably have no such file, but the splash screen still indicates what data file it is looking for - useful if the user cannot tell you what data file they used previously. Conclusion That should help you to look good, and give good support for the databases you develop. Popup Calendar
There are plenty of ActiveX control calendars, but they have issues with versioning, broken references, and handling Nulls. This is an ordinary Access form you can import into any database. Download the zip file (30 KB) for Access 2000 and later or Access 97. In Access 2007 and laber, there's a popup calendar built in, so this form is not needed (though it does work.) Just set the Show Date Picker property of the text box to "For dates." Adding the calendar to your database To use the calendar: Import form frmCalendar and module ajbCalendar into your database. Copy the calendar button from the sample form onto your form. (There are two styles to choose from.) Set the On Click property of the button to something like this: =CalendarFor([SaleDate], "Select the sale date") Change SaleDate to the name of your date text box. The quoted text is optional, for the title bar of the calendar. When you click the button, it pops up the calendar, reading the date from your text box. Select a date and click Ok, or double-click a date to write it back to your text box and close the calendar. Click Cancel to close the calendar without changing the date. The command button that opens the calendar is tied to a particular text box, so there is no problem with adding multiple command buttons and reusing the popup calendar if your form needs several dates. Keyboard shortcuts Left prior day Right next day Up prior week Down next week Home first of month End last of month Pg Up prior month Pg Dn next month Tips for using the calendar There are no restrictions on how you use this calendar. You may freely use it in any database you develop, for personal or commercial purposes. Pause the mouse over the question mark to see the list of keyboard shortcuts. Alt+T is the hotkey for today's date. A "Type Mismatch" message (Error 13) will occur if your text box contains a non-date value. To avoid this: If your text box is unbound, set its Format property to a date format (e.g. Short Date), so Access knows it is a date. Do not assign a non-date value (such as a zero-length-string) to your text box. Use Null instead, e.g.: Me.SaleDate = Null do not use a non-date value in the Default Value of your text box. As supplied, the calendar works only with text boxes, not combos. For other alternatives, Stephen Lebans has a wrapper class for the Microsoft Month Calendar Common Control. Tony Toews and Jeff Conrad list other options.
PRINTER SELECTION UTILITY You can design a report to use one of your printers by choosing Page Setup from the Page Setup ribbon (Access 2010), the Report Tools ribbon (Access 2007), or the File menu (previous versions.) But that approach is useless if others use your report: you do not know what printers they will have installed. This utility lets the end user assign one of their printers to each report. Whenever they open the report, it is sent to that printer. The utility works with MDE files and runtimeversions also. The utility also illustrates how to manipulate the Printer object and the Printers collection introduced in Access 2002. Click to download the utility (30KB, Access 2002/3 mdb format, zipped). You can also view the code from this utility. Overview The utility has a function named OpenTheReport() to use instead of DoCmd.OpenReport. The function checks to see if the user has assigned a particular printer for the report, and assigns the Printer before the report opens. To assign a printer, all the user has to do is preview the report, and click the custom Set Printer toolbar button. The utility remembers the choice, and uses that printer for that report in future.
Limitations The utility works only with Access 2002 and later. Albert Kallal has one for earlier versions: Access 97 or Access 2000. The utility does not let the user choose paper sizes. That can be done by opening the report in design view, and manipulating PrtMip. (Does not work for MDE.) To use the correct printer, you must use the supplied function, OpenTheReport(). Docmd.OpenReport works if the report is previewed, but not if it is opened straight to print. Opening reports with the New keyword (for multiple instances) is not supported. If you open several reports directly to print at once, the timing of the assignment of the Printer object may not operate correctly. To avoid this, program a delay between printing the reports, and include DoEvents. Copying the utility into your database To import the components of this utility into your database: Unzip the file. Open your database. In Access 2007 and later, click the Access icon in the Import group of the External Data ribbon. In previous versions, choose File | Get External | Import. Choose PrinterMgt.mdb as the file to import from. In the Import Objects dialog, click the Options button, and check the box Menus and Toolbars. On the Forms tab of the dialog, click frmSetPrinter. On the Modules tab, click ajbPrinter. Click Ok. The other form (frmSwitchboardExample) and the reports are included purely for demonstration purposes. To prepare your report to use this utility, open it in design view, and set these properties of the Report: On Close =SetupPrinter4Report() On Activate =SetupPrinter4Report([Report].[Name]) On Deactivate =SetupPrinter4Report() Toolbar ReportToolbar If you will use this with most of your reports, you may wish to set up a default report. In Access 2002, you must also include a reference to the Microsoft DAO 3.6 Library, by choosing References on the Tools menu from a code window. More information on references. Syntax of OpenTheReport() OpenTheReport() is the only function you need to learn. It is similar to the OpenReport method built into Access, but has several enhancements. Firstly, it looks to see if you have specified a printer to use for the report, and assigns it before opening the report. (If you use the old OpenReport to print directly (no preview), it will not use the desired printer.) Secondly, this function defaults to preview instead of printing directly. Thirdly, it avoids the problem where the report is not filtered correctly if it is already open. Fourthly, it does away with the FilterName argument that is rarely used, confusing, and inconsistent. Instead, it provides a simple way to pass a description of the filter to display on the report. Without an explanation of the filter, the printed report is meaningless, and printing the filter itself on the report may be too cryptic. You can therefore enter a description string. It is passed to the report through its OpenArgs. To display the description on the report, just add a text box with Control Source of: =[Report].[OpenArgs] Fifthly, it avoids the need to trap Error 2501 in every procedure where you open a report. OpenTheReport() traps and discards this annoying error message that can come from the report's NoData event, an impatient user, or some other problem. If you need to know whether the report opened, check the return value of the function. It will be True on success, or False if the report did not open. Examples: Code Explanation Call OpenTheReport("Report1") Open Report1 in preview. Call OpenTheReport("Report2", acViewNormal, _ "ClientID = 5", "Barney's orders only.") Send Report2 to the printer, filtered to client 5, with a description to print on the report. If Not OpenTheReport("Report5") Then MsgBox "Report did not open." End If Show a message if the report was cancelled. Argument reference: OpenTheReport() takes these arguments: strDoc - Name of the report to open. lngView - Optional. Use acViewPreview to open in preview (default), or acViewNormal to go straight to print. strWhere - Optional. A Where Condition to filter the report. strDescrip - Optional. A description you want to show on your report. (Passed via OpenArgs.) lngWindowMode - Optional. Use acWindowNormal for normal window (default), or acDialog for dialog mode. Note that strDoc, strWhere, and strDescrip are strings - not variants. The boring technical stuff When you assign a printer for a report, the utility creates a new property named Printer2Use. The property is type dbText, and stores the name of the user's printer. The property is created on the report document: CurrentDb().Containers("Reports").Documents("MyReport") since that property can be written and read without opening the report itself. The property is deleted if the user returns the report to the default printer. One advantage of using the custom property over a lookup table is that the assigned printer remains with the report even if the report is renamed, or duplicated. It turns out that you cannot merely set the Printer object in the Open event of the report. That works if the report is previewed, but not if it is sent straight to print. This is the reason the report must be opened through the function that sets the printer before the report is opened. The Printer setting is application-wide. If you have several reports open in preview at once, and switch between them, the utility needs to assign the correct printer to each one. The Activate and Deactivate events of the report achieve that. To restore the Printer object, to the Windows default, use: Set Application.Printer = Nothing. That destroys the object. Access then reconstructs it - from the default Windows printer. View the code if you wish. Code for Printer Selection Utility The code in this article is explained in the Printer Selection Utility. 'Author: Allen J Browne, 2004. allen@allenbrowne.com 'Versions: Access 2002 or later. (Uses Printer object.)
'Limitations: 1. May not work where multiple reports sent directly to print, without pause. ' 2. Reports must be opened using the OpenTheReport() function, ' so the printer is set *before* the report is opened. 'Methodology: Creates a custom property of the report document. ' The specified printer is therefore retained even if the report is renamed or copied. 'Explanation of this utility at: http://allenbrowne.com/AppPrintMgt.html
Option Compare Database Option Explicit
Private Const mstrcPropName = "Printer2Use" 'Name of custom property assigned to the report document. Private Const conMod = "basPrinter" 'Name of this module. Used by error handler. 'Use this function as a replacement for OpenReport. Function OpenTheReport(strDoc As String, _ Optional lngView As AcView = acViewPreview, _ Optional strWhere As String, _ Optional strDescrip As String, _ Optional lngWindowMode As AcWindowMode = acWindowNormal) As Boolean On Error GoTo Err_Handler 'Purpose: Wrapper for opening reports. 'Arguments: View = acViewPreview or acViewNormal. Defaults to preview. ' strWhere = WhereCondition. Passed to OpenReport. ' strDescrip = description of WhereCondition (passed as OpenArgs). ' WindowMode = acWindowNormal or acDialog. Defaults to normal. 'Return: True if opened. 'Notes: 1. Filter propery of OpenReport is not supported. ' 2. Suppresses error 2501 if report cancelled. Dim bCancel As Boolean Dim strErrMsg As String
'If the report is alreay open, close it so filtering is handled correctly. If CurrentProject.AllReports(strDoc).IsLoaded Then DoCmd.Close acReport, strDoc, acSaveNo End If
'Set the printer for this report (if custom property defined). strErrMsg = vbNullString Call SetupPrinter4Report(strDoc, strErrMsg) If Len(strErrMsg) > 0 Then strErrMsg = strErrMsg & vbCrLf & "Continue anyway?" If MsgBox(strErrMsg, vbYesNo + vbDefaultButton2, "Warning") <> vbYes Then bCancel = True End If End If
'Open the report If Not bCancel Then DoCmd.OpenReport strDoc, lngView, , strWhere, lngWindowMode, strDescrip OpenTheReport = True End If
Exit_Handler: Exit Function
Err_Handler: Select Case Err.Number Case 2501& 'Cancelled. 'do nothing Case 2467& 'Bad report name. MsgBox "No report named: " & strDoc, vbExclamation, "Cannot open report." Case Else Call LogError(Err.Number, Err.Description, conMod & ".OpenTheReport") End Select Resume Exit_Handler End Function Public Function SetupPrinter4Report(Optional strDoc As String, Optional strErrMsg As String) As String On Error GoTo Err_Handler 'Purpose: Set the application printer to the one specified for the report. 'Argument: Name of the report to prepare for. Omit to restore default printer. ' strErrMsg = message string to append problems to. 'Return: Name of the printer assigned, if successful. 'Usage: In On Activate property of report: ' =SetupPrinter4Report([Report].[Name]) ' In On Deactivate and On Close properties of report: ' =SetupPrinter4Report() Dim strPrinterName As String
If Len(strDoc) > 0 Then strPrinterName = GetPrinter4Report(strDoc, strErrMsg) End If 'Passing zero-length string restores default printer. If UsePrinter(strPrinterName, strErrMsg) Then SetupPrinter4Report = strPrinterName End If
Exit_Handler: Exit Function
Err_Handler: Call LogError(Err.Number, Err.Description, conMod & ".SetupPrinter4Report") Resume Exit_Handler End Function Public Function AssignReportPrinter(strDoc As String, strPrinterName As String) As Boolean On Error GoTo Err_Handler 'Purpose: Set or remove a custom property for the report for a particular printer. 'Arguments: strDoc = name or report. ' strPrinterName = name of printer. Zero-length string to remove property. 'Return: True on success. Dim db As DAO.Database Dim doc As DAO.Document Dim strMsg As String 'Error message. Dim bReturn As Boolean
'Get a reference to the report document. Set db = CurrentDb() Set doc = db.Containers("Reports").Documents(strDoc)
If Len(strPrinterName) = 0 Then 'Remove the property (if it exists). If HasProperty(doc, mstrcPropName) Then doc.Properties.Delete mstrcPropName End If bReturn = True Else 'Create or set the property. If SetPropertyDAO(doc, mstrcPropName, dbText, strPrinterName, strMsg) Then bReturn = True Else MsgBox strMsg, vbInformation, "Printer not set for report: " & strDoc End If End If
AssignReportPrinter = bReturn
Exit_Handler: Set doc = Nothing Set db = Nothing Exit Function
Err_Handler: Call LogError(Err.Number, Err.Description, conMod & ".AssignReportPrinter") Resume Exit_Handler End Function Public Function OpenFormSetPrinter() On Error GoTo Err_Handler 'Purpose: Open the form for setting the printer of the report on screen. 'Usage: Called from macMenu.SetPrinter. Dim strReport As String
strReport = Screen.ActiveReport.Name 'Fails if no report active. DoCmd.OpenForm "frmSetPrinter", WindowMode:=acDialog, OpenArgs:=strReport
Exit_Handler: Exit Function
Err_Handler: Select Case Err.Number Case 2501& 'OpenForm was cancelled. 'do nothing Case 2476& MsgBox "You must have a report active on screen to set a printer for it.", _ vbExclamation, "Cannot set printer for report" Case Else Call LogError(Err.Number, Err.Description, conMod & ".OpenFormSetPrinter") End Select Resume Exit_Handler End Function Public Function GetPrinter4Report(strDoc As String, Optional strErrMsg As String) As String On Error GoTo Err_Handler 'Purpose: Get the custom printer to use with the report. 'Argument: Name of the report to find the printer for. 'Return: Name of printer. Zero-length string if none specified, or printer no longer installed. Dim strPrinter As String Dim prn As Printer
'Get the name of the custom printer for the report. Error if none assigned. strPrinter = CurrentDb().Containers("Reports").Documents(strDoc).Properties(mstrcPropName)
If Len(strPrinter) > 0 Then 'Check that this printer still exists. Error if printer no longer exists. Set prn = Application.Printers(strPrinter) 'Return the printer name. GetPrinter4Report = strPrinter End If
Exit_Handler: Set prn = Nothing Exit Function
Err_Handler: Select Case Err.Number Case 3270& 'Property not found. 'do nothing: means use the default printer. Case 5& 'No such printer. strErrMsg = strErrMsg & "Custom printer not found: " & strPrinter & vbCrLf & _ "Default printer will be used." & vbCrLf Case Else Call LogError(Err.Number, Err.Description, conMod & ".GetPrinter4Report") End Select Resume Exit_Handler End Function Public Function UsePrinter(strPrinter As String, strErrMsg As String) As Boolean On Error GoTo Err_Handler 'Purpose: Make the named printer the active one. 'Arguments: Name of printer to assign. If zero-length string, restore default. ' Error message string to append to. 'Return: True if set (or already set).
'If no printer specified, restore the default (by unsetting). If Len(strPrinter) = 0 Then Set Application.Printer = Nothing Else 'Do nothing if printer is already set. If Application.Printer.DeviceName = strPrinter Then 'do nothing Else Set Application.Printer = Application.Printers(strPrinter) End If End If UsePrinter = True
Exit_Handler: Exit Function
Err_Handler: Select Case Err.Number Case 5 'Invalid printer. strErrMsg = strErrMsg & "Invalid printer: " & strPrinter & vbCrLf Case Else Call LogError(Err.Number, Err.Description, conMod & ".UsePrinter") End Select Resume Exit_Handler End Function '------------------------------------------------------------------------------------------------ 'You may prefer to replace this with a true error logger. See http://allenbrowne.com/ser-23a.html Function LogError(lngErrNum As Long, strErrDescrip As String, _ strCallingRoutine As String, Optional bShowUser As Boolean = True) Dim strMsg As String
If bShowUser Then strMsg = "Error " & lngErrNum & " - " & strErrDescrip MsgBox strMsg, vbExclamation, strCallingRoutine End If End Function Function SetPropertyDAO(obj As Object, strPropertyName As String, intType As Integer, _ varValue As Variant, Optional strErrMsg As String) As Boolean On Error GoTo ErrHandler 'Purpose: Set a property for an object, creating if necessary. 'Arguments: obj = the object whose property should be set. ' strPropertyName = the name of the property to set. ' intType = the type of property (needed for creating) ' varValue = the value to set this property to. ' strErrMsg = string to append any error message to.
If HasProperty(obj, strPropertyName) Then obj.Properties(strPropertyName) = varValue Else obj.Properties.Append obj.CreateProperty(strPropertyName, intType, varValue) End If SetPropertyDAO = True
ExitHandler: Exit Function
ErrHandler: strErrMsg = strErrMsg & obj.Name & "." & strPropertyName & " not set to " & _ varValue & ". Error " & Err.Number & " - " & Err.Description & vbCrLf Resume ExitHandler End Function Public Function HasProperty(obj As Object, strPropName As String) As Boolean 'Purpose: Return true if the object has the property. Dim varDummy As Variant
On Error Resume Next varDummy = obj.Properties(strPropName) HasProperty = (Err.Number = 0) End Function