Sage 100 ProvideX ODBC
Developer Reference
Practical patterns for querying, automating, and extracting value from Sage 100 data. What works, what doesn't, and how to build reporting pipelines that run unattended.
Overview
Sage 100 stores its data in a file-based ProvideX database. The ProvideX ODBC driver exposes this data through a SQL-like interface, letting you query it directly from PowerShell, Python, or any ODBC-capable environment — without going through the Sage reporting UI.
This opens up scheduled automated reports, real-time data pulls, cross-system integrations, and custom calculations that would be impractical inside Crystal Reports or Sage's built-in tools.
Connection
PowerShell is the recommended environment. It's available on every Windows machine, requires no additional dependencies beyond the ODBC driver, and integrates directly with Task Scheduler for unattended execution.
PowerShell
$conn = New-Object System.Data.Odbc.OdbcConnection(
"DSN=SOTAMAS90;UID=YourUser;PWD=YourPassword;COMPANY=COMPANY_FILE;"
)
$conn.Open()
$cmd = $conn.CreateCommand()
$cmd.CommandText = $query
$adapter = New-Object System.Data.Odbc.OdbcDataAdapter($cmd)
$ds = New-Object System.Data.DataSet
$adapter.Fill($ds) | Out-Null
$conn.Close()
# Results are in $ds.Tables[0].Rows
foreach ($row in $ds.Tables[0].Rows) {
Write-Host $row.CustomerNo $row.CustomerName
}
Python
import pyodbc
conn = pyodbc.connect(
"DSN=SOTAMAS90;UID=YourUser;PWD=YourPassword;COMPANY=COMPANY_FILE;"
)
cursor = conn.cursor()
cursor.execute("SELECT CustomerNo, CustomerName FROM AR_Customer LIMIT 10")
for row in cursor.fetchall():
print(row.CustomerNo, row.CustomerName)
conn.close()
Connection string anatomy
| Parameter | Value | Notes |
|---|---|---|
| DSN | SOTAMAS90 | Name of the System DSN you created in odbcad32. Any name works — this is convention. |
| UID | Sage username | Sage 100 user with data read access |
| PWD | Sage password | Store encrypted in production (see Task Scheduler section) |
| COMPANY | COMPANY_FILE, etc. | Your Sage company code — required |
SQL Rules
The ProvideX driver implements a subset of SQL that doesn't fully match any standard dialect. Knowing the quirks upfront saves a lot of debugging.
Key gotchas
- ✗No semicolons at end of query
- ✗No square brackets around names
- ✗Read-only — UPDATE/INSERT/DELETE will fail
- ✗No ORDER BY — sort in your script
- ✗No OR operator — use IN() instead
- ✗AND in compound WHERE is unreliable — test carefully
- ✗No subqueries
- ✗No string functions (CONCAT, TRIM, SUBSTRING, etc.) — handle in script
Date syntax
Dates require the ODBC escape format. Standard ISO strings don't work.
-- Exact date
WHERE InvoiceDate = {d'2025-01-15'}
-- Range
WHERE InvoiceDate BETWEEN {d'2025-01-01'} AND {d'2025-01-31'}
-- Greater than
WHERE InvoiceDate > {d'2025-02-12'}
-- PowerShell variable interpolation
$sql = "SELECT * FROM AR_InvoiceHistoryHeader WHERE InvoiceDate = {d'$dateStr'}"
Pagination
TOP is not supported. Use LIMIT and OFFSET.
SELECT * FROM AR_Customer LIMIT 100
SELECT * FROM AR_Customer LIMIT 100 OFFSET 100 -- rows 101-200
SELECT * FROM AR_Customer LIMIT 100 OFFSET 200 -- rows 201-300
String queries
-- LIKE (% wildcard works)
WHERE CustomerName LIKE 'Acme%'
WHERE CustomerName NOT LIKE '%test%'
-- IN instead of OR
WHERE CustomerNo IN ('0000001', '0000002', '0000003')
-- Numeric comparison
WHERE CustomerNo < '0000254'
-- Null checks
WHERE EmailAddress IS NOT NULL
WHERE InactiveReasonCode IS NULL
Function Support
All results from live production testing.
| Function / Clause | Status | Notes / Workaround |
|---|---|---|
| COUNT, SUM, AVG, MAX, MIN | Pass | All aggregate functions work normally |
| GROUP BY, HAVING | Pass | Standard behavior |
| DISTINCT | Pass | — |
| BETWEEN | Pass | Works for dates and numerics |
| LIKE / NOT LIKE | Pass | % wildcard supported |
| IN / NOT IN | Pass | Use IN instead of OR |
| IS NULL / IS NOT NULL | Pass | — |
| Date comparison {d} | Pass | Must use ODBC escape syntax |
| Inline math (+, -, *, /) | Pass | (qty * price) AS ExtAmt works |
| LIMIT / OFFSET | Pass | Use instead of TOP |
| INNER JOIN (IJ syntax) | Pass | One join per query. IJ is the most reliable join type. |
| ORDER BY | Fail | Sort in your script after retrieval |
| OR operator | Fail | Use IN() |
| AND in compound WHERE | Fail | Unreliable — filter on one field in SQL, rest in script |
| Subqueries | Fail | Not supported |
| CASE | Fail | Implement in script |
| CONCAT, UCASE, LCASE, SUBSTRING, TRIM, LEFT, REPLACE, LENGTH | Fail | All string functions unsupported — handle in script |
| ROUND, IFNULL | Fail | Handle in script |
| COUNT(DISTINCT) | Fail | — |
| UPDATE / INSERT / DELETE | Fail | Driver is read-only by design |
Joins
The ProvideX driver supports only one join per query, using a non-standard curly-brace syntax. IJ (Inner Join) is the most reliable and best-tested join type. Only use what you've verified in your environment.
Syntax
SELECT A.Field1, B.Field2
FROM
{ IJ TableA A
INNER JOIN TableB B
ON A.KeyField = B.KeyField }
Composite keys (header/detail tables)
Many Sage 100 header/detail relationships use composite keys. Include all key fields in the ON clause.
SELECT
H.InvoiceDate, H.InvoiceNo, H.CustomerNo,
D.ItemCode, D.QuantityShipped, D.UnitPrice,
(D.QuantityShipped * D.UnitPrice) AS ExtAmt
FROM
{ IJ AR_InvoiceHistoryHeader H
INNER JOIN AR_InvoiceHistoryDetail D
ON H.HeaderSeqNo = D.HeaderSeqNo AND H.InvoiceNo = D.InvoiceNo }
WHERE
H.InvoiceDate BETWEEN {d'2025-01-01'} AND {d'2025-01-31'}
Multi-table pattern
When you need data from three or more tables, run separate queries and join in PowerShell using a hashtable keyed on the shared field. This is the standard pattern for anything requiring data from more than two tables.
# Query 1: the join you can do in SQL
$queryMain = "SELECT D.VendorNo, D.ItemCode, H.CustomerNo, H.OrderDate
FROM { IJ AR_InvoiceHistoryDetail D
INNER JOIN AR_InvoiceHistoryHeader H
ON D.InvoiceNo = H.InvoiceNo }
WHERE H.OrderDate BETWEEN {d'$start'} AND {d'$end'}"
# Query 2: a separate table you can't join in SQL
$queryVendor = "SELECT VendorNo, VendorName FROM AP_Vendor"
# Execute both...
# Build a hashtable for O(1) lookup
$vendors = @{}
foreach ($v in $vendorTable.Rows) {
$vendors[$v.VendorNo] = $v.VendorName
}
# Join in PowerShell
$results = foreach ($row in $mainTable.Rows) {
[PSCustomObject]@{
ItemCode = $row.ItemCode
CustomerNo = $row.CustomerNo
VendorName = $vendors[$row.VendorNo] # joined here
OrderDate = $row.OrderDate
}
}
Script-Side Processing
Because ORDER BY, string functions, CASE, and ROUND don't work in ProvideX SQL, the practical pattern is: pull clean data with a tight WHERE clause, do everything else in your script.
Sorting
# Sort after retrieval — ORDER BY doesn't work in the driver
$results | Sort-Object InvoiceDate -Descending
$results | Sort-Object CustomerName, InvoiceDate
Calculated fields & rounding
# Inline math works in SQL, but ROUND() does not
# Option 1: let the spreadsheet round (usually fine)
# Option 2: round in PowerShell
$marginPct = [Math]::Round($row.MarginPercent, 2)
# Divide-by-zero guard (epsilon trick — works in SQL too)
# Add 1e-9 to denominator — rounds to 0 in any spreadsheet
# (D.QuantityShipped * D.UnitPrice + 1e-9)
String manipulation
# UCASE / LCASE don't exist in the driver — use PowerShell
$row.CustomerName.ToUpper()
$row.CustomerName.Trim()
$row.ItemCode.Substring(0, 3)
# CONCAT — use PowerShell string interpolation
"$($row.FirstName) $($row.LastName)"
Conditional logic (CASE replacement)
# CASE doesn't work in the driver — use a ternary or switch
$channel = switch -Regex ($row.WebOrderNo) {
'^(1|[gG])' { 'Web Channel 1' }
'^(2|[sS])' { 'Web Channel 2' }
default { 'Offline' }
}
# IFNULL — use null coalescing
$name = if ($row.VendorName) { $row.VendorName } else { 'Unknown' }
Export to Excel
# Install-Module ImportExcel (no Excel required)
$results | Sort-Object InvoiceDate -Descending |
Export-Excel -Path "C:\Reports\Sales_$(Get-Date -f yyyyMMdd).xlsx" `
-AutoSize -BoldTopRow -FreezeTopRow
Example Queries
Sales margin report
Inline math for extended amounts and margin %. The 1e-9 epsilon prevents divide-by-zero on zero-price lines.
SELECT
H.InvoiceDate, H.InvoiceNo, H.CustomerNo, H.BillToName,
D.ItemCode, D.ItemCodeDesc, D.ProductLine,
D.QuantityShipped AS Qty,
D.UnitPrice,
(D.QuantityShipped * D.UnitPrice) AS ExtAmt,
D.UnitCost,
(D.QuantityShipped * D.UnitCost) AS ExtCost,
(
(D.QuantityShipped * (D.UnitPrice - D.UnitCost))
/ (D.QuantityShipped * D.UnitPrice + 1e-9)
* 100
) AS MarginPct
FROM
{ IJ AR_InvoiceHistoryHeader H
INNER JOIN AR_InvoiceHistoryDetail D
ON H.HeaderSeqNo = D.HeaderSeqNo AND H.InvoiceNo = D.InvoiceNo }
WHERE
H.InvoiceDate BETWEEN {d'$startDate'} AND {d'$endDate'}
Open backorders
SELECT
H.SalesOrderNo, H.OrderDate, H.CustomerNo, H.BillToName,
D.ItemCode, D.ItemCodeDesc,
D.QuantityOrdered,
D.QuantityShipped,
(D.QuantityOrdered - D.QuantityShipped) AS QtyBackordered,
D.UnitPrice
FROM
{ IJ SO_SalesOrderHeader H
INNER JOIN SO_SalesOrderDetail D
ON H.SalesOrderNo = D.SalesOrderNo }
WHERE
D.QuantityOrdered > D.QuantityShipped
Commissions by salesperson
SELECT
SalespersonNo,
SUM(CommissionAmt) AS TotalCommission,
SUM(SalesAmt) AS TotalSales,
COUNT(InvoiceNo) AS InvoiceCount
FROM AR_SalespersonCommission
WHERE PeriodEndDate BETWEEN {d'2025-01-01'} AND {d'2025-12-31'}
GROUP BY SalespersonNo
AP open invoices
SELECT
VendorNo, VendorName, InvoiceNo,
InvoiceDate, DueDate, InvoiceAmt, Balance
FROM AP_OpenInvoice
WHERE Balance > 0
Key Tables
Table names follow the pattern MODULE_Entity. Module prefix maps to the Sage 100 module.
| Table | Module | Description |
|---|---|---|
| AR_Customer | AR | Customer master — names, addresses, terms, salesperson |
| AR_InvoiceHistoryHeader | AR | Posted invoice headers — dates, totals, customer, shipping |
| AR_InvoiceHistoryDetail | AR | Posted invoice line items — items, quantities, prices, costs |
| AR_OpenInvoice | AR | Unpaid AR invoices with balances |
| AR_SalespersonCommission | AR | Commission records by salesperson and period |
| SO_SalesOrderHeader | SO | Open/historical order headers |
| SO_SalesOrderDetail | SO | Order lines with qty ordered, shipped, backordered |
| AP_Vendor | AP | Vendor master — names, addresses, terms |
| AP_OpenInvoice | AP | Open AP invoices with balances and due dates |
| AP_InvoiceHistoryHeader | AP | Posted AP invoice headers |
| CI_Item | CI | Item master — codes, descriptions, costs, quantities |
| CI_ItemWarehouse | CI | Per-warehouse quantity and cost data |
| IM_AliasItem | IM | Alternate item codes / vendor part numbers |
| PO_PurchaseOrderHeader | PO | PO headers |
| PO_PurchaseOrderDetail | PO | PO lines with quantities ordered and received |
ODBC Setup (Without Sage Client)
The ProvideX driver can be installed on any Windows machine without a full Sage client. Useful for dedicated report servers or task scheduler machines.
Steps
- Copy Pvxio.dll, Pvxodbc.dll, and Pvxsec32.dll from C:\Windows\SysWOW64\ on a working Sage workstation to the same path on your target machine.
- Import the registry file below to register the driver. Update the serial key from your source workstation if needed.
- Install Visual C++ redistributables. Missing VC++ runtimes cause cryptic errors both when registering the driver and when running scripts. The TechPowerUp all-in-one installer covers everything from 2005 onward in one shot. Use Dependency Walker to diagnose missing DLLs if needed.
- Open C:\Windows\SysWOW64\odbcad32.exe, create a System DSN using MAS 90 4.0 ODBC Driver, and point it at your Sage data directory (e.g., \\server\MAS90\MAS_COMPANY_FILE).
Registry file
Windows Registry Editor Version 5.00
[HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\ODBC\ODBCINST.INI\MAS 90 4.0 ODBC Driver]
"Driver"="PVXODBC.DLL"
"Setup"="PVXODBC.DLL"
"SQLLevel"="0"
"DriverODBCVer"="03.00"
"APILevel"="1"
"ConnectFunctions"="YYN"
"FileUsage"="1"
"UsageCount"="dword:0000000"
[HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Sage Software\ProvideX ODBC Driver]
"Serial"="500001-1-5DE5C882FC4DBEB6"
"Name"="Registered MAS 90 & MAS 200 user"
"Company"="Sage Software, Inc."
[HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\ODBC\ODBCINST.INI\ODBC Drivers]
"MAS 90 4.0 ODBC Driver"="Installed"
Windows Task Scheduler
Task Scheduler is the right tool for running these scripts unattended. No additional dependencies — just PowerShell and the ODBC driver. The key capability: run whether or not a user is logged in.
Critical settings
Must configure
- Run whether user is logged on or not
- Run with highest privileges
- -ExecutionPolicy Bypass -NonInteractive in arguments
- Start when possible after missed trigger
Watch out for
- Stored credentials expire when you change your Windows password
- Scripts fail silently with no logged-in user — build CSV logging into every script
- Plaintext credentials in script files — use encrypted PSCredential exports in prod
PowerShell task registration
$action = New-ScheduledTaskAction `
-Execute "powershell.exe" `
-Argument "-ExecutionPolicy Bypass -NonInteractive -File C:\Scripts\DailyReport.ps1"
$trigger = New-ScheduledTaskTrigger -Daily -At "06:00AM"
$settings = New-ScheduledTaskSettingsSet `
-RunOnlyIfNetworkAvailable `
-StartWhenAvailable `
-ExecutionTimeLimit (New-TimeSpan -Minutes 30)
$principal = New-ScheduledTaskPrincipal `
-UserId "DOMAIN\YourUser" `
-LogonType Password `
-RunLevel Highest
Register-ScheduledTask `
-TaskName "DailyInvoiceSummary" `
-Action $action -Trigger $trigger `
-Settings $settings -Principal $principal
Logging pattern
Scripts running without a session have no console. Write a structured log row on every run — success or failure.
$logRow = $null
try {
# ... your script work ...
$logRow = [PSCustomObject]@{
Date = Get-Date -f "yyyy-MM-dd"
Result = "OK"
Error = ""
}
}
catch {
$logRow = [PSCustomObject]@{
Date = Get-Date -f "yyyy-MM-dd"
Result = "FAIL"
Error = $_.Exception.Message
}
}
finally {
# Prepend so newest row is always at top
if (Test-Path $logPath) {
@($logRow) + (Import-Csv $logPath) | Export-Csv $logPath -NoTypeInformation
} else {
$logRow | Export-Csv $logPath -NoTypeInformation
}
}