Passing Excel VBA array to parental function? - vba

I don't know if this question will make sense to begin with...
Example Given: the following value is given for a single cell (we'll call it A1): Sub-value #1|Here's another sub-value #2|Yet again, last but not least, sub-value #3. I already know someone will tell me that this is where a database should be used (trust me, my major is DB Management, I know, but I need my data in this fashion). My delimiter is the |. Now say I want to create a function that will take the LEN() of each sub-value and return the AVERAGE() of all the sub-values. If I wanted to create a single function to do this, I could use an split(), take each value, do an LEN() and return the AVERAGE().
For the example given, let's utilize cell B1. I have created similar functions in the past that would work by the following method (although not this exact one), but it requires splitting and joining the array/cell value(s) each time: =ARRAY_AVERAGE(ARRAY_LEN(A1,"|","|"),"|","|").
ARRAY_LEN(cell,delimiter[,Optional new_delimiter])
ARRAY_AVERAGE(cell,delimiter[,Optional new_delimiter])
However, I'm wondering if there might be a more dynamic approach to this. Basically, I want to split() an array with some custom VBA function, pass it to parent cell functions, and I wrap up the array by a function that will merge the array back together.
Here's how the cell function will run:
=ARRAY_AVERAGE(ARRAY_LEN(ARRAY_SPLIT(A1,"|"))).
ARRAY_SPLIT(cell,delimiter) will split the array.
ARRAY_LEN(array) will return the length of each sub-value of the array.
ARRAY_AVERAGE(array) will return the average of each sub-value of the array. Since this function returns a single value of multiple values, this will take the form of an imaginary ARRAY_JOIN(array,delimiter) that would merge the array back again.
This requires one or two additional functions in the cells, but it also lowers the number of iterations that the cell would be converting to and from a single cell value and VBA array.
What do you think? Possible? Feasible? More or less code efficient?

Now, this is a very crude example but it should give you an idea of how to get started and how you can customize this method to suit your needs. Assume you have the following data in a text file called example.txt :
Name|Age|DoB|Data1|Data2|Data3
David|25|1987-04-08|100|200|300
John|42|1960-06-21|400|500|600
Sarah|15|1997-02-01|700|800|900
This file resides in the folder C:\Downloads. To query this in VBA using ADO you'll need to reference the Microsoft ActiveX Data Objects 2.X Library where X is the latest version you have installed. I also reference the Microsoft Scripting Library to create my Schema.ini files at run-time to ensure that my data is read properly. Without the Schema.ini file you run the risk of your data not being read as you expect it to be by the driver. Numbers as text can ocassionally be read as null for no reason and dates often get returned null as well. The Schema.ini file gives the text driver an exact definition of your data and how to handle it. You don't HAVE to define every column explicitly like I have done but at the very least you should set your Format, ColNameHeader, and DateTimeFormat values.
Example Schema.ini file used:
[example.txt]
Format=Delimited(|)
ColNameHeader=True
DateTimeFormat=yyyy-mm-dd
Col1=Name Char
Col2=Age Integer
Col3=DoB Date
Col4=Data1 Integer
Col5=Data2 Integer
Col6=Data3 Integer
You'll notice that the file name is enclosed in brackets on the first line. This is NOT optional and it also allows you to define different schemas for different files. As mentioned earlier I create my Schema.ini file in VBA at run-time with something like the following:
Sub CreateSchema()
Dim fso As New FileSystemObject
Dim ts As TextStream
Set ts = fso.CreateTextFile(FILE_DIR & "Schema.ini", True)
ts.WriteLine "[example.txt]"
ts.WriteLine "Format=Delimited(|)"
ts.WriteLine "ColNameHeader=True"
ts.WriteLine "DateTimeFormat=yyyy-mm-dd"
ts.WriteLine "Col1=Name Char"
ts.WriteLine "Col2=Age Integer"
ts.WriteLine "Col3=DoB Date"
ts.WriteLine "Col4=Data1 Integer"
ts.WriteLine "Col5=Data2 Integer"
ts.WriteLine "Col6=Data3 Integer"
Set fso = Nothing
Set ts = Nothing
End Sub
You'll notice that I use the variable FILE_DIR which is a constant I define at the top of my module. Your Schema.ini file -MUST- reside in the same location as your data file. The connection string for your query also uses this directory so I define the constant to make sure they reference the same place. Here's the top of my module with the FILE_DIR constant along with the connection string and SQL query:
Option Explicit
Const FILE_DIR = "C:\Downloads\"
Const TXT_CONN = "Driver={Microsoft Text Driver (*.txt; *.csv)};Dbq=" & FILE_DIR & ";Extensions=asc,csv,tab,txt;"
Const SQL = "SELECT Name, DoB, ((Data1 + Data2 + Data3)/3) AS [Avg_of_Data]" & _
"FROM example.txt "
Notice the portion in TXT_CONN called Dbq. This is the directory where your data file(s) are stored. You'll actually define the specific file you use in the WHERE clause of your SQL string. The SQL constant contains your query string. In this case we're just selecting Name, DoB, and Averaging the three data values. With all of that out of the way you're ready to actually execute your query:
Sub QueryText()
Dim cn As New ADODB.Connection
Dim rs As New ADODB.Recordset
Dim i As Integer
'Define/open connection
With cn
.ConnectionString = TXT_CONN
.Open
'Query text file
With rs
.Open SQL, cn
.MoveFirst
'Loop through/print column names to Immediate Window
For i = 0 To .Fields.Count - 1
Debug.Print .Fields(i).Name
Next i
'Loop through recordset
While Not (.EOF Or .BOF)
'Loop through/print each column value to Immediate Window
For i = 0 To .Fields.Count - 1
Debug.Print .Fields(i)
Next i
.MoveNext
Wend
.Close 'Close recordset
End With
.Close 'Close connection to file
End With
Set rs = Nothing
Set cn = Nothing
End Sub
I know that I said doing this is extremely simple in my comments above and that this looks like a lot of work but I assure you it's not. You could use ONLY the QueryText() method and end up with similar results. However, I've included everything else to try and give you some ideas of where you can take this for your project as well as to show you how to solve problems you might run into if you're not getting the results back that you expected.
This is the guide I originally learned from: http://msdn.microsoft.com/en-us/library/ms974559.aspx
Here is a guide for doing the same thing to actual Excel files: http://support.microsoft.com/kb/257819
Lastly, here's more info on Schema.ini files: http://msdn.microsoft.com/en-us/library/windows/desktop/ms709353(v=vs.85).aspx
Hopefully you're able to find a way to make use of all this information in your line of work! A side benefit to learning all of this is that you can use ADO to query actual databases like Access, SQL Server, and Oracle. The code is nearly identical to what is printed here. Just swap out the connection string, sql string, and ignore the whole bit about a Schema.ini file.

Here are 2 example VBA UDFs that work on a single cell: enter the formula as
=AVERAGE(len_text(SPLIT_TEXT(A1,"|")))
Note that in this particular case you don't actually need the len_text function, you could use Excel's LEN() instead, but then you would have to enter the AVERAGE(..) as an array formula.
Option Explicit
Public Function Split_Text(theText As Variant, Delimiter As Variant) As Variant
Dim var As Variant
var = Split(theText, Delimiter)
Split_Text = Application.WorksheetFunction.Transpose(var)
End Function
Public Function Len_Text(something As Variant) As Variant
Dim j As Long
Dim k As Long
Dim var() As Variant
If IsObject(something) Then
something = something.Value2
End If
ReDim var(LBound(something) To UBound(something), LBound(something, 2) To UBound(something, 2))
For j = LBound(something) To UBound(something)
For k = LBound(something, 2) To UBound(something, 2)
var(j, k) = Len(something(j, k))
Next k
Next j
Len_Text = var
End Function

Related

defining code on vba excel to simplify code writing process

I am attempting to reduce the amount of clutter on my code by creating "shortcuts" if you will
For instance, I always have to type
ThisWorkBook.ActiveSheet.Range
Is there a way for me to define the above to create a less wordy macro? I have tried convert to range and string and the former returns an error (but I could still get intellisense recognize and attempt to autofill) while the string version doesnt work.
Just like in any programming language, you can use variables to store data
For example:
Dim myrange As Range: Set myrange = Sheets("Sheet1").Range("B5")
Alternatively, if you will be working with the same object multiple times, you can use the With keyword
For example. instead of writing you want to work with table every time on every new line you can do
With Sheets("Sheet1").ListObjects("Table1")
.ListRows.Add
.ListColumns(2).Range(3) = "Hello World!"
' ... and so on
End With
Also, please on a sidenote: Avoid using Select/ActiveSheet/ActiveWorkbook and so on!
More info on how to here
You can create functions or customized properties, which are always evaluated when called
Property Get pARng As Range
Set pARng = ThisWorkBook.ActiveSheet.Range
End Property
Function fARng As Range
Set fARng = ThisWorkBook.ActiveSheet.Range
End Function
'Usage
Sub xxx
'...
pARng.Rows(1).Activate
'Same as ThisWorkBook.ActiveSheet.Range.Rows(1).Activate
fARng.Rows(1).Activate
'using function instead achieves same result
End Sub

How to debug.print a query result from the query design in VBA /Access

I want to execute a predefined query from access via VBA and print it to the debug output. The name of the query design is in a variable named report.
I was expecting that it would work with:
debug.print db.Execute report
But everytime vba autocorrects it to:
debug.print db.Execute; report
If I understand it correctly, the ; stands for a new line and makes therefore no sense in my case. But in both cases, with and without the new line I get an error. I assume that the simple problem is, that this is not the right syntax.
I could find a lot of information about how to debug print a query that is created as a string in VBA, but I can't find any hints how to output a query that is predefined in Access via a query design.
Try either:
'// You need the parentheses because the result has to be evaluated before it can be passed to the .Print() method
Debug.Print db.Execute(result)
or
Dim myResult As String
myResult = db.Execute(result)
Debug.Print myResult
In VBA you can pass arguments to a procedure/method without using parentheses, as long as the result isn't being being assigned to anything.
'// Not assigning anything
MyExampleFunction arg1, arg2, arg3
'// assigning the result, which needs to be evaluated before it can be passed to a variable.
MyResult = MyExampleFunction(arg1, arg2, arg3)
In the same way, when you call Debug.Print it assumes that db.Execute and result are actually separate arguments (it has no way of knowing that you want result to be passed to db.Execute first because there's nothing in your syntax to state that). So you have to use parentheses to let it know.
It seems as the problem was that it is only possible to call updates with db.Execute and not queries.
There is no good solution to print a whole table from a query with debug.print but using a RecordSet as seen in the following code is a possible way.
Dim rs As DAO.RecordSet
Set rs = db.OpenRecordset(Bericht)
Do Until rs.EOF = True
Debug.Print rs("Matrikelnummer"), rs("Bewertung"), rs("Termin (Datum)"), rs("Maximale Bewertung"), rs("Bestehensgrenze"), rs("Versuch"), rs("Äquivalenzleistung")
rs.MoveNext
Loop
rs.Close
Set rs = Nothing

VB6 map string to integer for headers

I'm trying to parse a CSV File into a VB6 application in order to update multiple records on a table on SQL with existing single record updating code already in the form. The CSV Files will have a header row whixh can be used to validate the information going into the correct place in the ADODB recordset. In C++ you can use a map to say like
map<String s, int x> column
column<"First Name", -1>
column<"Last Name",-1>
Then create a counter across the comma delimited values where if the third value is Last Name then the code could be written to change
column<"Last Name",-1> to column<"Last Name",3> and if x != -1 in any of the maps the file is valid for use, I would then loop through the remaining records and parse into a container using something similar to
strLastName = Array<column[3]>
to assign the record values to the correct variables. I am still very new to VB6, how can I accomplish something similar in VB6 and what containers should be used? So far I have
Public Sub GetImportValues()
On Error GoTo GetImportValues_Error:
Dim intFileNum As Integer
Open Path For Input As #intFileNum
Do Until EOF(intFileNum)
Line Input #intFileNum, vbCrLf
FunctionThatSavesInformationToSQL
Loop
Close #intFileNum
GetImportValues_Exit:
Exit Sub
GetImportValues_Error:
Err.Source = "frmMemberAdd.GetImportValues" & " | " & Err.Source
Err.Raise Err.Number, Err.Source, Err.Description
End Sub
with a dialog box returning the path as a string using App.path in a separate Function
*****************************************************Slight change to answer
The collection was on track for what I had asked but I did have to change it to dictionary because you cannot return items on a collection which kept me from comparing the items and changing the keys but dictionary can. Make sure if you use dictionary you switch the item and key.
If I understand your question correctly, you're trying to create a map (Dictionary<string, int> in C#). In VB6, you can use Collection for this purpose - it's roughly equivalent to C#'s Dictionary<string, object>. It uses String keys and stores all values as Variant. For example:
Dim oColl As Collection
Set oColl = New Collection
oColl.Add -1, "ColumnName"
Dim nColumnIndex As Long
'Get column index for column name.
nColumnIndex = oColl.Item("ColumnName")
If nColumnIndex = -1 Then
nColumnIndex = ...
'When you want to update a column index in the collection, you
'first have to remove the item and then add it back with the right
'index.
oColl.Remove "ColumnName"
oColl.Add nColumnIndex, "ColumnName"
End If
Edit 1:
One word of warning regarding VB6: you'll see many samples doing this:
Dim oObj As New SomeClass
It's ok to do this in VB.Net but don't ever do this in VB6. Declare and instantiate the object on separate statements because the single-statement form generates code where oObj is checked for Nothing and set to an instance before each use. This slows down your code (unnecessary checks) and creates hard-to-find bugs if you're using an instance that's supposed to be gone.
Always do this instead:
Dim oObj As SomeClass
Set oObj = New SomeClass
...
'Clean up the object when you're done with it. Remember, there's
'no garbage collection in COM / VB6, you have to manage object
'lifetimes.
Set oObj = Nothing
Also, use Long instead of Integer as much as you can - Long is a 32-bit integer, while Integer is only 16-bits. VB6 type names can be misleading frequently. Here's an old answer of mine with a bit more detail (not strictly related to your question but useful).
Alternatively, you can create a simplified wrapper around the .NET Dictionary class and expose it as a COM object: this would allow you to call it from VB6. This would likely be (somewhat) slower than Collection and it'd require the .NET Framework for your VB6 project to run.
Edit 2:
As #CMaster commented, Dictionary is available from the Microsoft Scripting Runtime library - you need to add a reference to it to use it (this is why I prefer Collection - it has no dependency). This answer has details about how to use it.

Populate a userform Combobox with a list of subdirectory names in a defined directory

I apologize if this question was answered previously on this board. My searches didn't turn up what I'm looking for. I am a VBA novice and would like to know if there is a way to populate a userform combobox with the names of all subdirectories contained within a predefined directory (I need the list to be updated every time the userform is launched). I've seen some code that does this on other websites but they were for earlier versions of Excel and I could not get them to work. I am using Excel 2007. I appreciate any help you may be able to provide.
Option Explicit
Private Sub UserForm_Initialize()
Dim name
For Each name In ListDirectory(Path:="C:\", AttrInclude:=vbDirectory, AttrExclude:=vbSystem Or vbHidden)
Me.ComboBox1.AddItem name
Next name
End Sub
Function ListDirectory(Path As String, AttrInclude As VbFileAttribute, Optional AttrExclude As VbFileAttribute = False) As Collection
Dim Filename As String
Dim Attribs As VbFileAttribute
Set ListDirectory = New Collection
' first call to Dir() initializes the list
Filename = Dir(Path, AttrInclude)
While Filename <> ""
Attribs = GetAttr(Path & Filename)
' to be added, a file must have the right set of attributes
If Attribs And AttrInclude And Not (Attribs And AttrExclude) Then
ListDirectory.Add Filename, Path & Filename
End If
' fetch next filename
Filename = Dir
Wend
End Function
A few notes, since you said you had little experience with VBA.
Always have Option Explicit in effect. No excuses.
Dir() is used in VB to list files.
Collections are a lot more convenient than arrays in VBA.
There are named parameters available in function calls (name:=value). You don't have to use them, but they help to make sense of long argument lists. Argument order is irrelevant if you use named parameters. You cannot mix named and unnamed parameters, though.
You can have optional arguments with default values.
Note that assigning to the function name (ListDirectory in this case) sets the result of a function. You can therefore use the function name directly as a variable inside that function.
Set AttrInclude to -1 if you want to return all types of files. Conveniently, -1 is the numerical value of True., i.e. ListDirectory("C:\", True).
Set AttrExclude to 0 if you want to exclude no files. Conveniently, 0 is the numerical value of False., i.e. ListDirectory("C:\", True, False), which also is the default.
All logical operators in VB 6.0 are bit-wise, hence you can check whether a file is a directory by using If Attribs And VbDirectory Then ...
You can combine multiple bit values with Or, e.g. vbSystem Or vbHidden.
Consequently, you can filter directories with a simple bit-wise logic check.
Use the Object Browser (hit F2) to inspect available Functions, Types and Constants, for example the constants in the VbFileAttribute enum.

Access VBA with custom function in SQL

I need to open a query or recordset or something (datasheet view) with some sql i build in my vba based on form control values. I have a "many to many" relationship in my data (kind of). Pretend we have a Person table, a Pet table and an ID Pet_Person table. I want to have a list of my People with a concatenated string of all their pets in one row of returned data. So....
Row Person Pets
1 Kim Lucius, Frodo, Cricket, Nemo
2 Bob Taco
And I googled and I found you can write functions in the VBA to be called from the SQL. So. Here's the problem. I have a lot of records and once opened, I cannot move around the datasheet view/query/whatever without that concatenation function being called everytime I click on a row. I only need it called once when the sql is initially ran... like a snapshot or something.
I'm not too well versed with Access and the possibilities. I've tried some things I found online that all had the same result... that concatenation function being called when I touched that resulting dataset at all.
Last thing I tried looks something like:
With db
Set qdf = .CreateQueryDef("Search Results", q)
DoCmd.OpenQuery "Search Results", , acReadOnly
.QueryDefs.Delete "Search Results"
End With
StackOverflow really never formats my stuff correctly. Probably user error.... oh, well.
Edit:
Oh Bart S. Thank you but you went away too soon for me to understand the answer if it is there. Thank you.
Oh Remou. Yes, I saw your post. I used your post. I've used many of your posts while working on this project. Why access doesn't support all SQL functions I am so used to with MySQL I have no idea. You're a great addition to this site. I should have linked to it with my question but the coffee hadn't kicked in yet.
I have my concatenation function and I am calling it within the sql. I was opening it with the docmd to open that recorset or query or whatever. But here is my issue (and I may be creating this myself by trying too many solutions at once or I might be overlooking something)... it keeps calling that function each time I touch the resulting dataset/query/thing and there's too much data for that to be happening; I am seeing the hourglass simply too much. I am positive this is because of the way I am opening it. This is intended to be the result of a search form screen thing. I'm thinking I need to just literally make another form in access and populate it with my resulting recordset. I think that is what you guys are telling me. I'm not sure. This is a weird example. But... you know with Excel, when you write an inline function of some kind to get some value for each row... and then you do a copy and paste special for just values (so not the function)... I need that. Because this function (not in Excel, obviously) must query and that takes to long to reapply each time a row is clicked on (I think it's actually requerying each row if a single row is clicked on, almost like it's rerunning the sql or something). Like the NIN/Depeche Mode song Dead Souls... It keeps calling me/it.
Here are a few thoughts and strategies for coping with the issue of constant data re-loading:
Make sure your query is set to snapshot. Same for the form.
This of course makes the data read-only, but it may help a bit.
Cache the result of your query into a local table, then show/bind that table instead of the query itself.
This will make the user wait a bit longer initially while the query is executed and saved into the local table, but it makes the interface much smoother afterwards since all data is local and doesn't need to be re-calculated.
Create a local table localPetResult (on the client side) that has all the fields matching those of the query.
Instead of binding the query itself to the datasheet form, bind the localPetResult to it, then in the form's VBA module handle the OnLoad event:
Private Sub Form_Load()
' Remove all previous data from the local cache table '
CurrentDb().Execute "DELETE FROM localPetResult"
' Define the original query '
Dim q as String
q = q & "SELECT Row, "
q = q & " Person, "
q = q & " Coalesce(""SELECT PetName FROM Pets WHERE Person='"" & [Person] & ""',"","") AS PetNames "
q = q & "FROM MyData"
' Wrap the query to insert its results into the local table '
q = "INSERT INTO localPetResult " & q
' Execute the query to cache the data '
CurrentDb().Execute q
End Sub
One you have it working, you can improve on this in many ways to make it nicer (freeze the screen and display the hourglass, dynamically bind the ersult table to the form after the data has been calculated, etc)
Cache the result of each call to the coalescing function.
I've used that to calculate the concatenation once for each record, then store the result in a Dictionary whose key is the ID of the record. Subsequent calculations for the same ID are just pulled from the Dictionary instead of re-calculated.
For instance, add the following to a VBA module. I'll assume that you use Remou's Coalesce function as well.
Option Compare Database
Option Explicit
' A Scripting.Dictionary object we'll use for caching '
Private dicCache As Object
' Call to initialise/reset the cache before/after using it '
Public Sub ResetCoalesceCache()
If Not (dicCache Is Nothing) Then
dicCache.RemoveAll
End If
End Sub
' Does the Same as Coalesce() from Remou, but attempts to '
' cache the result for faster retrieval later '
Public Function Coalesce2(key As Variant, _
sql As String, _
sep As String, _
ParamArray NameList() As Variant) As Variant
' Create the cache if it hasn't been initialised before '
If dicCache Is Nothing Then
Set dicCache = CreateObject("Scripting.Dictionary")
End If
If IsNull(key) Then
' The key is invalid, just run the normal coalesce '
Coalesce2 = Coalesce(sql, sep, NameList)
ElseIf dicCache.Exists(key) Then
' Hurray, the key exists in the cache! '
Coalesce2 = dicCache(key)
Else
' We need to calculate and then cache the data '
Coalesce2 = Coalesce(sql, sep, NameList)
dicCache.Add(key, Coalesce2)
End If
End Function
Then, to use it in your query:
' first clear the cache to make sure it doesn't contain old '
' data that could be returned by mistake '
ResetCoalesceCache
' Define the original query '
Dim q as String
q = q & "SELECT Row, "
q = q & " Person, "
q = q & " Coalesce2([Row], ""SELECT PetName FROM Pets WHERE Person='"" & [Person] & ""',"","") AS PetNames "
q = q & "FROM MyData"
' Bind to your form or whatever '
...
I always do it like this:
Dim strSql As String
strSql = "SELECT * FROM table WHERE field=something;"
Set rs = CurrentDb.OpenRecordSet(strSql)
Then use RS to perform actions. There may be better ways. You can, for example, create a query directly in Access and call it from VBA.
While looping the recordset, you can concatenate the string:
Dim strResult As String
While (Not rs.EOF)
strResult = strResult & rs!yourfield
WEnd