VBA/Sql.bas
Paul Trowbridge 3e5a31a5bf Add modular SQL/DB VBA library and TBFC forecast loader
Start a modern, modular successor to TheBigOne (side-by-side; TheBigOne
untouched):

- Sql.bas: stateless SQL-text generation from a 2D Variant array (header
  in row 1). Type inference from Excel cell VarType, dialect-aware
  identifier quoting/literals, VALUES batch + INSERT...SELECT, and
  CreateTableIfMissing. Successor to the SQLp_* helpers.
- Db.cls: single connection object, late-bound ADO (no reference needed),
  SQLOLEDB by default (zero-install / non-admin friendly). Open/Exec/Query
  with structured LastError. Successor to the ADOp_* helpers.
- TBFCLoad.bas: thin caller that loads the "Upload" tab into
  fanalysis.GS.TBFC (auto-create, replace-by-Source). Tested vs USMIDSQL01.
- load_tbfc.py: equivalent Python loader kept as a power-user alternative.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 12:01:13 -04:00

240 lines
9.1 KiB
QBasic

Attribute VB_Name = "Sql"
'==========================================================================
' Sql - generate SQL text from a 2D array (modern successor to SQLp_* in
' TheBigOne). Stateless, no connection, no ORM - it just writes SQL.
'
' Convention: callers pass a 2D Variant array whose FIRST ROW is the header
' (exactly what Range.Value returns for a block with a header row). Column
' SQL types are inferred from the Excel cell VarType per column:
' date cells -> DATE
' numeric cells -> DECIMAL(18, scale)
' everything else (incl. text-stored codes) -> N/VARCHAR(len)
'
' Final insert shape is INSERT ... SELECT * FROM (VALUES ...) x(cols) - a
' derived table, so SQL Server's 1000-row VALUES limit does not apply.
'==========================================================================
Option Explicit
Public Enum SqlDialect
dlSqlServer = 0
dlPostgres = 1
End Enum
Public Enum ColKind
ckText = 0
ckNumber = 1
ckDate = 2
End Enum
'----- low-level building blocks ------------------------------------------
' Quote an identifier for the dialect, escaping the close-quote char.
Public Function BracketId(ByVal name As String, ByVal dialect As SqlDialect) As String
If dialect = dlSqlServer Then
BracketId = "[" & Replace(name, "]", "]]") & "]"
Else
BracketId = """" & Replace(name, """", """""") & """"
End If
End Function
' One SQL literal for a value of the given kind. maxLen > 0 truncates text.
Public Function Literal(ByVal v As Variant, ByVal kind As ColKind, _
ByVal dialect As SqlDialect, Optional ByVal maxLen As Long = 0) As String
If IsEmpty(v) Or IsNull(v) Or IsError(v) Then
Literal = "NULL"
Exit Function
End If
Select Case kind
Case ckDate
If IsDate(v) Then
Literal = "'" & Format$(CDate(v), "yyyy-mm-dd") & "'"
Else
Literal = "NULL"
End If
Case ckNumber
If Not IsNumeric(v) Or Len(Trim$(CStr(v))) = 0 Then
Literal = "NULL"
Else
' Str$ is locale-invariant ('.' decimal, leading space for sign)
Literal = Trim$(Str$(v))
End If
Case Else ' ckText
Dim s As String
s = CStr(v)
If maxLen > 0 And Len(s) > maxLen Then s = Left$(s, maxLen)
Literal = "'" & Replace(s, "'", "''") & "'"
End Select
End Function
' Map a column kind to a SQL type. DECIMAL is valid in both dialects.
Public Function SqlType(ByVal kind As ColKind, ByVal dialect As SqlDialect, _
Optional ByVal numScale As Long = 2, Optional ByVal textLen As Long = 100) As String
Select Case kind
Case ckDate
SqlType = "DATE"
Case ckNumber
SqlType = "DECIMAL(18," & numScale & ")"
Case Else
If dialect = dlSqlServer Then
SqlType = "NVARCHAR(" & textLen & ")"
Else
SqlType = "VARCHAR(" & textLen & ")"
End If
End Select
End Function
'----- inference & lookups ------------------------------------------------
' Infer a ColKind per column from the first non-empty data cell.
' Returns a Long() indexed to match the data's column bounds.
Public Function InferKinds(ByVal data As Variant) As Variant
Dim rLo As Long, rHi As Long, cLo As Long, cHi As Long, i As Long, j As Long
rLo = LBound(data, 1): rHi = UBound(data, 1)
cLo = LBound(data, 2): cHi = UBound(data, 2)
Dim k() As Long
ReDim k(cLo To cHi)
For j = cLo To cHi
k(j) = ckText
For i = rLo + 1 To rHi ' skip header row
If Not IsEmpty(data(i, j)) Then
If Not IsError(data(i, j)) Then
Select Case VarType(data(i, j))
Case vbDate
k(j) = ckDate
Case vbDouble, vbSingle, vbInteger, vbLong, vbCurrency, vbDecimal
k(j) = ckNumber
Case Else
k(j) = ckText
End Select
Exit For
End If
End If
Next i
Next j
InferKinds = k
End Function
' 1-based-or-whatever column index of a header by name; 0 if not found.
Public Function ColIndex(ByVal data As Variant, ByVal headerName As String) As Long
Dim cLo As Long, cHi As Long, j As Long, hdr As Long
hdr = LBound(data, 1)
cLo = LBound(data, 2): cHi = UBound(data, 2)
For j = cLo To cHi
If StrComp(Trim$(CStr(data(hdr, j))), headerName, vbTextCompare) = 0 Then
ColIndex = j
Exit Function
End If
Next j
ColIndex = 0
End Function
' Distinct non-empty text values in a column (skips header). 0-based array.
Public Function DistinctText(ByVal data As Variant, ByVal colIndex As Long) As Variant
Dim d As Object
Set d = CreateObject("Scripting.Dictionary")
d.CompareMode = vbTextCompare
Dim rLo As Long, rHi As Long, i As Long, s As String
rLo = LBound(data, 1): rHi = UBound(data, 1)
For i = rLo + 1 To rHi
If Not IsEmpty(data(i, colIndex)) And Not IsError(data(i, colIndex)) Then
s = Trim$(CStr(data(i, colIndex)))
If Len(s) > 0 Then If Not d.Exists(s) Then d.Add s, True
End If
Next i
DistinctText = d.Keys
End Function
'----- composite generators -----------------------------------------------
' Comma list of bracketed header names, e.g. [Date], [Account GL], ...
Public Function ColumnList(ByVal data As Variant, ByVal dialect As SqlDialect) As String
Dim cLo As Long, cHi As Long, j As Long, hdr As Long
hdr = LBound(data, 1)
cLo = LBound(data, 2): cHi = UBound(data, 2)
Dim parts() As String
ReDim parts(cLo To cHi)
For j = cLo To cHi
parts(j) = BracketId(CStr(data(hdr, j)), dialect)
Next j
ColumnList = Join(parts, ", ")
End Function
' Quote a 1D array of values as a comma list for an IN (...) clause (text).
Public Function InList(ByVal values As Variant, ByVal dialect As SqlDialect) As String
If IsEmpty(values) Then Exit Function
Dim lo As Long, hi As Long, i As Long
lo = LBound(values): hi = UBound(values)
If hi < lo Then Exit Function
Dim parts() As String
ReDim parts(lo To hi)
For i = lo To hi
parts(i) = "'" & Replace(CStr(values(i)), "'", "''") & "'"
Next i
InList = Join(parts, ", ")
End Function
' SELECT * FROM (VALUES (..),(..)) x([col],[col]) over the data rows.
Public Function ValuesSelect(ByVal data As Variant, ByVal dialect As SqlDialect, _
Optional ByRef kinds As Variant, Optional ByVal textLen As Long = 0) As String
Dim rLo As Long, rHi As Long, cLo As Long, cHi As Long, i As Long, j As Long, r As Long
rLo = LBound(data, 1): rHi = UBound(data, 1)
cLo = LBound(data, 2): cHi = UBound(data, 2)
If IsMissing(kinds) Then kinds = InferKinds(data)
Dim rows() As String, cells() As String
ReDim rows(1 To rHi - rLo) ' data rows = total - header
ReDim cells(cLo To cHi)
For i = rLo + 1 To rHi
For j = cLo To cHi
cells(j) = Literal(data(i, j), kinds(j), dialect, textLen)
Next j
r = r + 1
rows(r) = "(" & Join(cells, ",") & ")"
Next i
ValuesSelect = "SELECT * FROM (VALUES" & vbCrLf & _
Join(rows, "," & vbCrLf) & vbCrLf & _
") x (" & ColumnList(data, dialect) & ")"
End Function
' Full INSERT INTO <fq> (cols) SELECT * FROM (VALUES ...) x(cols).
Public Function InsertSelectValues(ByVal fq As String, ByVal data As Variant, ByVal dialect As SqlDialect, _
Optional ByRef kinds As Variant, Optional ByVal textLen As Long = 0) As String
If IsMissing(kinds) Then kinds = InferKinds(data)
InsertSelectValues = "INSERT INTO " & fq & " (" & ColumnList(data, dialect) & ")" & vbCrLf & _
ValuesSelect(data, dialect, kinds, textLen)
End Function
' DDL that creates <fq> only if it doesn't already exist, with inferred types.
Public Function CreateTableIfMissing(ByVal fq As String, ByVal data As Variant, ByVal dialect As SqlDialect, _
Optional ByRef kinds As Variant, _
Optional ByVal numScale As Long = 2, Optional ByVal textLen As Long = 100) As String
If IsMissing(kinds) Then kinds = InferKinds(data)
Dim cLo As Long, cHi As Long, j As Long, hdr As Long
hdr = LBound(data, 1)
cLo = LBound(data, 2): cHi = UBound(data, 2)
Dim parts() As String
ReDim parts(cLo To cHi)
For j = cLo To cHi
parts(j) = BracketId(CStr(data(hdr, j)), dialect) & " " & SqlType(kinds(j), dialect, numScale, textLen)
Next j
Dim ddl As String
ddl = Join(parts, ", ")
If dialect = dlSqlServer Then
Dim plain As String
plain = Replace(Replace(fq, "[", ""), "]", "")
CreateTableIfMissing = "IF OBJECT_ID('" & plain & "','U') IS NULL CREATE TABLE " & fq & " (" & ddl & ")"
Else
CreateTableIfMissing = "CREATE TABLE IF NOT EXISTS " & fq & " (" & ddl & ")"
End If
End Function