Calculating Donor Level

M

Mark Andrews

Access2007

I have a donation database where I calculate the "DonorLevel" of a donor
based on how much they have donated.

Example: 0 to $500 might be "silver level" and $500 to $2500 might be gold
level.

I allow up to 8 levels and store the ranges in one record in a table called
"tblPreferences"

I usually calculate DonorLevel on the fly for an individual donor with some
code.

Now I'm at the point where I want to make reports. Example a simple report
groupBy DonorLevel and show total of all donations by all donors in that
donor level.

My question is what's the best approach to write queries that are quick.

Currently I have a query that calls a function for each donor to return
their donor level.
It's dog slow but works. For example: I have 5000 donors and about 25000
donations, so this query calculates for each of the 5000 donors their level
and then I use this query in another query which calculates the total
donations grouped by donor level.

My next thought was to actually have a field in the Donor table and update
the donor level (this column would be used just as a temporary place to hold
donorlevel at any specific moment in time).

Looking for suggestions,
Mark
 
D

Dirk Goldgar

Mark Andrews said:
Access2007

I have a donation database where I calculate the "DonorLevel" of a donor
based on how much they have donated.

Example: 0 to $500 might be "silver level" and $500 to $2500 might be gold
level.

I allow up to 8 levels and store the ranges in one record in a table
called "tblPreferences"

I usually calculate DonorLevel on the fly for an individual donor with
some code.

Now I'm at the point where I want to make reports. Example a simple
report
groupBy DonorLevel and show total of all donations by all donors in that
donor level.

My question is what's the best approach to write queries that are quick.

Currently I have a query that calls a function for each donor to return
their donor level.
It's dog slow but works. For example: I have 5000 donors and about 25000
donations, so this query calculates for each of the 5000 donors their
level and then I use this query in another query which calculates the
total donations grouped by donor level.

My next thought was to actually have a field in the Donor table and update
the donor level (this column would be used just as a temporary place to
hold donorlevel at any specific moment in time).


I would not save this calculated value in the table unless there is no
practical way around it.

What do your "dog slow" query and the function it calls look like? There
may well be something in your query or function design that is slowing
things down unnecessarily.
 
M

Mark Andrews

Dirk,
Thanks for helping out. I have one query that calls this function and
returns ContactID and DonorLevel.
I use the first query in a second query to calculate totals by donor level.

My function is as follows:

Public Function LookupDonorLevel(TotalCollected As Double) As String
On Error GoTo Err_LookupDonorLevel
Dim rs As DAO.Recordset
Dim txtDonorLevel As String

Set rs = CurrentDb.OpenRecordset("tblPreferences", dbOpenDynaset)
If Not (rs.BOF And rs.EOF) Then
rs.MoveFirst
If (TotalCollected <= rs("DonorLevel1End")) Then
txtDonorLevel = rs("DonorLevel1Name")
ElseIf (TotalCollected <= rs("DonorLevel2End")) Then
txtDonorLevel = rs("DonorLevel2Name")
ElseIf (TotalCollected <= rs("DonorLevel3End")) Then
txtDonorLevel = rs("DonorLevel3Name")
ElseIf (TotalCollected <= rs("DonorLevel4End")) Then
txtDonorLevel = rs("DonorLevel4Name")
ElseIf (TotalCollected <= rs("DonorLevel5End")) Then
txtDonorLevel = rs("DonorLevel5Name")
ElseIf (TotalCollected <= rs("DonorLevel6End")) Then
txtDonorLevel = rs("DonorLevel6Name")
ElseIf (TotalCollected <= rs("DonorLevel7End")) Then
txtDonorLevel = rs("DonorLevel7Name")
ElseIf (TotalCollected <= rs("DonorLevel8End")) Then
txtDonorLevel = rs("DonorLevel8Name")
Else
txtDonorLevel = ""
End If
End If
rs.Close

LookupDonorLevel = txtDonorLevel

Exit_LookupDonorLevel:
Set rs = Nothing
Exit Function

Err_LookupDonorLevel:
MsgBox Err.Description
Resume Exit_LookupDonorLevel
End Function


Thanks,
Mark
 
M

Mark Andrews

Here's more info:
It takes a few minutes to run. End report might show 5 donor levels and
their values.
25000 donations, 5000 contacts approx.

"qryReportDonationAnalysisByDonorLevelSubquery"
has this sql:
-----------------------------
SELECT tblDonation.ContactID, LookupDonorLevel(Sum([DonationAmount])) AS
DonorLevel
FROM tblDonation INNER JOIN qryFilter ON tblDonation.ContactID =
qryFilter.ContactID
GROUP BY tblDonation.ContactID;
-----------------------------

This query is used for the report:
-----------------------------
SELECT qryReportDonationAnalysisByDonorLevelSubquery.DonorLevel,
Sum(tblDonation.DonationAmount) AS TotalDonations
FROM qryReportDonationAnalysisByDonorLevelSubquery INNER JOIN tblDonation ON
qryReportDonationAnalysisByDonorLevelSubquery.ContactID =
tblDonation.ContactID
WHERE
(((tblDonation.DonationDate)>=[forms]![frmReports].[txtHiddenStartDate] And
(tblDonation.DonationDate)<=[forms]![frmReports].[txtHiddenEndDate]))
GROUP BY qryReportDonationAnalysisByDonorLevelSubquery.DonorLevel;
-----------------------------




Public Function LookupDonorLevel(TotalCollected As Double) As String
On Error GoTo Err_LookupDonorLevel
Dim rs As DAO.Recordset
Dim txtDonorLevel As String

Set rs = CurrentDb.OpenRecordset("tblPreferences", dbOpenDynaset)
If Not (rs.BOF And rs.EOF) Then
rs.MoveFirst
If (TotalCollected <= rs("DonorLevel1End")) Then
txtDonorLevel = rs("DonorLevel1Name")
ElseIf (TotalCollected <= rs("DonorLevel2End")) Then
txtDonorLevel = rs("DonorLevel2Name")
ElseIf (TotalCollected <= rs("DonorLevel3End")) Then
txtDonorLevel = rs("DonorLevel3Name")
ElseIf (TotalCollected <= rs("DonorLevel4End")) Then
txtDonorLevel = rs("DonorLevel4Name")
ElseIf (TotalCollected <= rs("DonorLevel5End")) Then
txtDonorLevel = rs("DonorLevel5Name")
ElseIf (TotalCollected <= rs("DonorLevel6End")) Then
txtDonorLevel = rs("DonorLevel6Name")
ElseIf (TotalCollected <= rs("DonorLevel7End")) Then
txtDonorLevel = rs("DonorLevel7Name")
ElseIf (TotalCollected <= rs("DonorLevel8End")) Then
txtDonorLevel = rs("DonorLevel8Name")
Else
txtDonorLevel = ""
End If
End If
rs.Close

LookupDonorLevel = txtDonorLevel

Exit_LookupDonorLevel:
Set rs = Nothing
Exit Function

Err_LookupDonorLevel:
MsgBox Err.Description
Resume Exit_LookupDonorLevel
End Function
 
D

Dirk Goldgar

Mark Andrews said:
Dirk,
Thanks for helping out. I have one query that calls this function and
returns ContactID and DonorLevel.
I use the first query in a second query to calculate totals by donor
level.

My function is as follows:

Public Function LookupDonorLevel(TotalCollected As Double) As String
On Error GoTo Err_LookupDonorLevel
Dim rs As DAO.Recordset
Dim txtDonorLevel As String

Set rs = CurrentDb.OpenRecordset("tblPreferences", dbOpenDynaset)
If Not (rs.BOF And rs.EOF) Then
rs.MoveFirst
If (TotalCollected <= rs("DonorLevel1End")) Then
txtDonorLevel = rs("DonorLevel1Name")
ElseIf (TotalCollected <= rs("DonorLevel2End")) Then
txtDonorLevel = rs("DonorLevel2Name")
ElseIf (TotalCollected <= rs("DonorLevel3End")) Then
txtDonorLevel = rs("DonorLevel3Name")
ElseIf (TotalCollected <= rs("DonorLevel4End")) Then
txtDonorLevel = rs("DonorLevel4Name")
ElseIf (TotalCollected <= rs("DonorLevel5End")) Then
txtDonorLevel = rs("DonorLevel5Name")
ElseIf (TotalCollected <= rs("DonorLevel6End")) Then
txtDonorLevel = rs("DonorLevel6Name")
ElseIf (TotalCollected <= rs("DonorLevel7End")) Then
txtDonorLevel = rs("DonorLevel7Name")
ElseIf (TotalCollected <= rs("DonorLevel8End")) Then
txtDonorLevel = rs("DonorLevel8Name")
Else
txtDonorLevel = ""
End If
End If
rs.Close

LookupDonorLevel = txtDonorLevel

Exit_LookupDonorLevel:
Set rs = Nothing
Exit Function

Err_LookupDonorLevel:
MsgBox Err.Description
Resume Exit_LookupDonorLevel
End Function


If I were doing this, I would have the DonorLevels defined in a table with
one record per level and fields like these:

DonorLevelName (text, primary key)
DonorLevelStart (currency, indexed w/no duplicates)
DonorLevelEnd (currency, indexed w/no duplicates)

DonorLevelStart would be the dollar value of the low end of this donor
level, and DonorLevelEnd would be the high end. Technically, you only need
one of these fields, but it makes the SQL simpler if you have both.

With a table like that, I could determine the DonorLevel for any given
donation total without any code at all, using straight SQL:

------ start of "air SQL" ------

SELECT
Donors.ContactID,
Donors.TotalDonation,
DonorLevels.DonorLevel
FROM
(
SELECT
tblDonation.ContactID,
Sum(DonationAmount) AS TotalDonation
FROM tblDonation INNER JOIN qryFilter
ON tblDonation.ContactID = qryFilter.ContactID
GROUP BY tblDonation.ContactID)
) AS Donors
LEFT JOIN
DonorLevels
ON Donors.TotalDonation
BETWEEN DonorLevels.DonorLevelStart
AND DonorLevels.DonorLevelEnd
ORDER BY Donors.DonorLevel

------ end of "air SQL" ------

If you don't choose to go that route, and you want to retain your function,
you can fix the big problem you're having with performance by not opening
and closing a recordset to return the same data each time the function is
called. That's easy enough; just collect the information in static
variables, checking in each function call to see if you need to reload them:

'------ start of (alternative) revised code ------
Public Function LookupDonorLevel(TotalCollected As Double) As String

On Error GoTo Err_LookupDonorLevel

Static L1End As Variant, L1Name As String
Static L2End As Variant, L2Name As String
Static L3End As Variant, L3Name As String
Static L4End As Variant, L4Name As String
Static L5End As Variant, L5Name As String
Static L6End As Variant, L6Name As String
Static L7End As Variant, L7Name As String
Static L8End As Variant, L8Name As String

Dim rs As DAO.Recordset
Dim txtDonorLevel As String

If IsEmpty(L1End) Then
Set rs = CurrentDb.OpenRecordset("tblPreferences", dbOpenSnapshot)
With rs
If .EOF Then
L1End = 0
L2End = 0
L3End = 0
L4End = 0
L5End = 0
L6End = 0
L7End = 0
L8End = 0
Else
L1End = !DonorLevel1End : L1Name = !DonorLevel1Name
L2End = !DonorLevel2End : L2Name = !DonorLevel2Name
L3End = !DonorLevel3End : L3Name = !DonorLevel3Name
L4End = !DonorLevel4End : L4Name = !DonorLevel4Name
L5End = !DonorLevel5End : L5Name = !DonorLevel5Name
L6End = !DonorLevel6End : L6Name = !DonorLevel6Name
L7End = !DonorLevel7End : L7Name = !DonorLevel7Name
L8End = !DonorLevel8End : L8Name = !DonorLevel8Name
End If
.Close
End With
Set rs = Nothing
End If

Select Case TotalCollected
Case Is <= L1End : txtDonorLevel = L1Name
Case Is <= L2End : txtDonorLevel = L2Name
Case Is <= L3End : txtDonorLevel = L3Name
Case Is <= L4End : txtDonorLevel = L4Name
Case Is <= L5End : txtDonorLevel = L5Name
Case Is <= L6End : txtDonorLevel = L6Name
Case Is <= L7End : txtDonorLevel = L7Name
Case Is <= L8End : txtDonorLevel = L8Name
Case Else : txtDonorLevel = ""
End Select

LookupDonorLevel = txtDonorLevel

Exit_LookupDonorLevel:
On Error Resume Next
If Not rs Is Nothing Then
rs.Close
Set rs = Nothing
End If
Exit Function

Err_LookupDonorLevel:
MsgBox Err.Description
Resume Exit_LookupDonorLevel
End Function

'------ end of code ------

The above code could doubtless be made less repetitive through the use of an
array, but you you get the idea -- and besides, it probably wouldn't be any
more efficient.

Note that, if you change the donor levels in the table, you'd need to exit
and re-enter the database, or reset the VB project, to get the new donor
levels to be loaded into the static variables. If that's not desirable, you
can add an argument to the function that would serve as a flag to force the
levels to be reloaded.
 
M

Mark Andrews

Dirk,

Thanks for the advice. Tomorrow I will try out some of these suggestions.
Most of the time I just retrieve the donor level for one contact. I'll
probably try to optimize the current function first.

I also think about my design of having the eight levels in tblPreferences.

Thanks for your help,
My MVP of the year still goes out to you,
Mark
 
J

John W. Vinson

Thanks for the advice. Tomorrow I will try out some of these suggestions.
Most of the time I just retrieve the donor level for one contact. I'll
probably try to optimize the current function first.

Any Query will be VASTLY faster than any such VBA function... which must run a
query just to get started, with the overhead of the function added on to that.

Use the right tool for the task. You can drive nails with a crescent wrench,
but that doesn't mean it's a good hammer!
 
S

SF

This Function may help.

Public Function DonorLevel(Amt As Double) As String

Select Case Amt

Case 0
DonorLevel = "N/A"
Case 1 To 500
DonorLevel = "Silver"
Case 500 To 1000
DonorLevel = "Gold"
Case Is > 1000
DonorLevel = "Diamond"
End Select

End Function

SF
 
M

Mark Andrews

Dirk/John,

FYI:
If I just change the function to read in the levels and names once and then
use the variables from then on makes the report run in about a second (for
25,000 donations).

I implemented it slightly different than how you suggested. One sub to set
the vars and one function that uses the vars (used in the query).

I'm not sure if calling a function that is basically just a big if statement
multiple times in a query would be faster or slower than if I had to run the
extra sql multiple times? Now that the function doesn't do any recordset
opening etc....

I think I'm going to keep it this way for now. Next time I implement
something like this I'll look at using a table with rows for each donor
level. I have too much other code using this method right now to change.

Thanks for looking at it and offering suggestions!
Mark

SF,
My levels and names are defined by the user, so slightly different logic is
needed.

Mark
 
J

John W. Vinson

Access2007

I have a donation database where I calculate the "DonorLevel" of a donor
based on how much they have donated.

Example: 0 to $500 might be "silver level" and $500 to $2500 might be gold
level.

I allow up to 8 levels and store the ranges in one record in a table called
"tblPreferences"

I usually calculate DonorLevel on the fly for an individual donor with some
code.

No code is needed *at all*.

Assuming you have a table or query with a field Donation, and that your
tblPreferences table has three fields, Low, High and DonorLevel:

SELECT <whatever fields you want to see>, tblPreferences.DonorLevel
FROM <yourtable> INNER JOIN tblPreferences
ON <yourtable>.Donation >= tblPreferences.Low
AND <yourtable>.Donation < tblPreferences.High
WHERE <criteria to select which donations>

You can use DonorLevel in the GROUP BY clause of the query if you wish, sort
by it, whatever.
 
M

Mark Andrews

Yea I should of made a seperate table for donor levels with rows of data to
hold the donor levels. Instead I built everything around a table with one
row of data and fields for each of the 8 donor levels, highs and lows.
Currently I only allow up to 8 donor levels.

I'm wavering on going back and redesigning the tables, forms etc....
Probably would of been easier to design the form the user uses to enter
donor levels with the proper design.
I guess I was brain dead that day?

Mark
 

Ask a Question

Want to reply to this thread or ask your own question?

You'll need to choose a username for the site, which only take a couple of moments. After that, you can post your question and our members will help you out.

Ask a Question

Top