Equivalence report
Northwind: Access 97 database and its migrated version, compared row by row
Northwind is the sample database Microsoft shipped with Access. We migrated a public copy of it to SQLite and PostgreSQL and ported its saved queries to SQL views. This report is produced by the automated tests that compare the new databases with the original file.
9 of 9 tables, 22 of 22 saved queries (855 runs), 5 of 5 calculations and 17 of 17 constraint tests. Two intentional differences are documented below: exact discount arithmetic and text clean-up.
Source and method
The source is nwind.mdb, the Northwind sample in Access 97 (Jet 3) format, taken from the public test data of the mdbtools project. It is Microsoft's demonstration database: the companies and people in it are fictitious. The file was only read, never opened in Access, and nothing stored in it was executed.
Original file
- File
- nwind.mdb (Microsoft Northwind sample, Access 97 / Jet 3)
- Size
- 3,002,368 bytes
- SHA-256
- 4682dfc91be526e6508948cc53adf5c63d70fdf7f2cc1f1403ee76b66ac914b2
- Unchanged after the run
- Yes
Engines and readers
- Original, tables
- mdbtools 1.0 (independent of the migration) and Jackcess (used by the migration)
- Original, Access SQL
- UCanAccess 5.1.3 (Ucanaccess 3.x.x), HSQLDB in memory
- New, SQLite
- SQLite 3.45.1
- New, browser
- sql.js / SQLite 3.49.1 (sql.js 1.14.2, WebAssembly, Node v22.22.0)
- New, PostgreSQL
- PostgreSQL 16.15 (Ubuntu 16.15-0ubuntu0.24.04.1), loaded from the delivered scripts in 0.6 s
What is compared
- Tables. Row counts and a SHA-256 checksum of every row, matched by primary key, between the original file (read with mdbtools) and each new database. Each value is first written in a canonical form: amounts with four decimals, dates as YYYY-MM-DD, pictures as their pixels. The documented text clean-up is applied to the original side, and every value it changed is checked against the migration log. As a cross-check, the two readers of the original file (mdbtools and Jackcess) must agree with each other.
- Queries. For every saved query, the complete result set (all columns, all rows, duplicates counted) is compared four ways: (1) with an independent model of the Access query written in Python and computed from the original tables; (2) with the Access SQL text executed on the original file by UCanAccess; (3) with the same view in PostgreSQL; (4) with the same view in the browser engine of the web app. Where a query has an
ORDER BY, the order of the sort keys is also compared. - Calculations. Order totals, invoice figures, sales by category and year, sales by year and quarter and the grand total, compared in the same four ways.
- Constraints. Statements that break a rule of the original database are sent to the new databases, which must refuse them.
Tables: 9 of 9 identical
Every table of the original file was migrated, including Umsätze, a German-named copy of Orders that we found in the file (same 830 rows, same values). It is kept as a read-only archive table. The 17 pictures stored as OLE objects were converted to PNG; their pixels are compared, and the original bytes are kept in legacy_ole_objects (17 of 17 byte-identical to the file).
| New table | Access table | Access | SQLite | PostgreSQL | Browser | Rows differing | Checksum | Values cleaned | Result |
|---|---|---|---|---|---|---|---|---|---|
categories | Categories | 8 | 8 | 8 | 8 | 0 | 55c260ba0358… ✓ | – | Pass |
customers | Customers | 91 | 91 | 91 | 91 | 0 | 3ca49870a5ae… ✓ | 10 | Pass |
employees | Employees | 9 | 9 | 9 | 9 | 0 | 41560b505f91… ✓ | 3 | Pass |
shippers | Shippers | 3 | 3 | 3 | 3 | 0 | 657121d69884… ✓ | – | Pass |
suppliers | Suppliers | 29 | 29 | 29 | 29 | 0 | 5a54d4e6b31c… ✓ | 11 | Pass |
products | Products | 77 | 77 | 77 | 77 | 0 | fdbcf701fd18… ✓ | – | Pass |
orders | Orders | 830 | 830 | 830 | 830 | 0 | 48c0b261ad43… ✓ | 62 | Pass |
order_details | Order Details | 2,155 | 2,155 | 2,155 | 2,155 | 0 | ca6fb9b12ec4… ✓ | – | Pass |
legacy_umsaetze | Umsätze | 830 | 830 | 830 | 830 | 0 | 48c0b261ad43… ✓ | 62 | Pass |
| Total | 4,032 | 4,032 | 4,032 | 4,032 | 0 | 148 |
Saved queries: 22 of 22 equivalent
Each of the 22 saved queries is now a view with the same name in snake case, in both SQLite and PostgreSQL. The three parameter queries became views without parameters; the application supplies the values through a WHERE clause, as listed in db/parameterized.sql. Invoices Filter was run for each of the 830 orders. Open a query to see its original SQL and how it was run.
About the Access SQL column. UCanAccess reads the Access 97 discount column as a whole number, so every discount arrives as 0 (see tool findings). To compare like with like, the views are run for this check on a control copy of the new database in which the discounts are also 0. Real discounts are covered by the Python model, PostgreSQL and the browser.
Alphabetical List of Products→ alphabetical_list_of_products · 1 run · 69 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
- New SQL
SELECT … FROM alphabetical_list_of_products
SELECT DISTINCTROW Products.*, Categories.CategoryName FROM Categories INNER JOIN Products ON Categories.CategoryID = Products.CategoryID WHERE (((Products.Discontinued)=No));
Catalog→ catalog · 1 run · 69 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
- New SQL
SELECT … FROM catalog
SELECT DISTINCTROW Categories.CategoryName, Categories.Description, Categories.Picture, Products.ProductID, Products.ProductName, Products.QuantityPerUnit, Products.UnitPrice FROM Categories INNER JOIN Products ON Categories.CategoryID = Products.CategoryID WHERE (((Products.Discontinued)=No));
Category Sales for 1995→ category_sales_for_1995 · 1 run · 8 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
- New SQL
SELECT … FROM category_sales_for_1995- Run on the original with
- A1 Two-digit year literals written with four digits (
#1/1/95#→#1/1/1995#). UCanAccess rejects the short form; Access reads 95 as 1995.
A3 A saved query that UCanAccess could not load is written inline as a derived table (the SQL text is the saved one). - Discount arithmetic
- 6 of 8 rows would differ by one cent or more under Access's floating-point arithmetic (D1); the Access SQL comparison uses the control copy.
SELECT DISTINCTROW [Product Sales for 1995].CategoryName, Sum([Product Sales for 1995].ProductSales) AS CategorySales FROM [Product Sales for 1995] GROUP BY [Product Sales for 1995].CategoryName;
Current Product List→ current_product_list · 1 run · 69 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
- New SQL
SELECT … FROM current_product_list- Order (product_name)
- Same order of sort keys as Access SQL
SELECT [Product List].ProductID, [Product List].ProductName FROM Products AS [Product List] WHERE ((([Product List].Discontinued)=No)) ORDER BY [Product List].ProductName;
Customers and Suppliers by City→ customers_and_suppliers_by_city · 1 run · 120 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
- New SQL
SELECT … FROM customers_and_suppliers_by_city- Order (city, company_name)
- Same order of sort keys as Access SQL
SELECT City, CompanyName, ContactName, "Customers" AS [Relationship] FROM Customers UNION SELECT City, CompanyName, ContactName, "Suppliers" FROM Suppliers ORDER BY City, CompanyName;
Employee Sales by Country→ employee_sales_by_country · 3 runs · 1,299 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
- New SQL
SELECT … FROM employee_sales_by_countryWHERE shipped_date BETWEEN ? AND ?- Parameter values
- 1995-01-01 .. 1995-12-31; 1994-01-01 .. 1996-12-31; 1995-07-01 .. 1995-09-30
- Run on the original with
- A2 Parameter query: the
PARAMETERSclause or form reference is replaced by literal values, one run per value set. The new view receives the same values through aWHEREclause. - Discount arithmetic
- 28 of 1,299 rows would differ by one cent or more under Access's floating-point arithmetic (D1); the Access SQL comparison uses the control copy.
PARAMETERS Beginning Date DateTime, Ending Date DateTime; SELECT DISTINCTROW Employees.Country, Employees.LastName, Employees.FirstName, Orders.ShippedDate, Orders.OrderID, [Order Subtotals].Subtotal AS SaleAmount FROM Employees INNER JOIN (Orders INNER JOIN [Order Subtotals] ON Orders.OrderID = [Order Subtotals].OrderID) ON Employees.EmployeeID = Orders.EmployeeID WHERE (((Orders.ShippedDate) Between [Beginning Date] And [Ending Date]));
Invoices→ invoices · 1 run · 2,155 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
- New SQL
SELECT … FROM invoices- Discount arithmetic
- 18 of 2,155 rows would differ by one cent or more under Access's floating-point arithmetic (D1); the Access SQL comparison uses the control copy.
SELECT DISTINCTROW Orders.ShipName, Orders.ShipAddress, Orders.ShipCity, Orders.ShipRegion, Orders.ShipPostalCode, Orders.ShipCountry, Orders.CustomerID, Customers.CompanyName, Customers.Address, Customers.City, Customers.Region, Customers.PostalCode, Customers.Country, [FirstName] & " " & [LastName] AS Salesperson, Orders.OrderID, Orders.OrderDate, Orders.RequiredDate, Orders.ShippedDate, Shippers.CompanyName, [Order Details].ProductID, Products.ProductName, [Order Details].UnitPrice, [Order Details].Quantity, [Order Details].Discount, CCur([Order Details].[UnitPrice]*[Quantity]*(1-[Discount])/100)*100 AS ExtendedPrice, Orders.Freight FROM Shippers INNER JOIN (Products INNER JOIN ((Employees INNER JOIN (Customers INNER JOIN Orders ON Customers.CustomerID = Orders.CustomerID) ON Employees.EmployeeID = Orders.EmployeeID) INNER JOIN [Order Details] ON Orders.OrderID = [Order Details].OrderID) ON Products.ProductID = [Order Details].ProductID) ON Shippers.ShipperID = Orders.ShipVia;
Invoices Filter→ invoices · 830 runs · 2,155 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
- New SQL
SELECT … FROM invoicesWHERE order_id = ?- Parameter values
- 830 values (OrderID=10330 ... OrderID=11077)
- Run on the original with
- A2 Parameter query: the
PARAMETERSclause or form reference is replaced by literal values, one run per value set. The new view receives the same values through aWHEREclause.
A3 A saved query that UCanAccess could not load is written inline as a derived table (the SQL text is the saved one). - Discount arithmetic
- 18 of 2,155 rows would differ by one cent or more under Access's floating-point arithmetic (D1); the Access SQL comparison uses the control copy.
SELECT DISTINCTROW Invoices.* FROM Invoices WHERE (((Invoices.OrderID)=[Forms]![Orders]![OrderID]));
Order Details Extended→ order_details_extended · 1 run · 2,155 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
- New SQL
SELECT … FROM order_details_extended- Order (order_id)
- Same order of sort keys as Access SQL
- Discount arithmetic
- 18 of 2,155 rows would differ by one cent or more under Access's floating-point arithmetic (D1); the Access SQL comparison uses the control copy.
SELECT DISTINCTROW [Order Details].OrderID, [Order Details].ProductID, Products.ProductName, [Order Details].UnitPrice, [Order Details].Quantity, [Order Details].Discount, CCur([Order Details].[UnitPrice]*[Quantity]*(1-[Discount])/100)*100 AS ExtendedPrice FROM Products INNER JOIN [Order Details] ON Products.ProductID = [Order Details].ProductID ORDER BY [Order Details].OrderID;
Order Subtotals→ order_subtotals · 1 run · 830 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
- New SQL
SELECT … FROM order_subtotals- Discount arithmetic
- 17 of 830 rows would differ by one cent or more under Access's floating-point arithmetic (D1); the Access SQL comparison uses the control copy.
SELECT DISTINCTROW [Order Details].OrderID, Sum(CCur([UnitPrice]*[Quantity]*(1-[Discount])/100)*100) AS Subtotal FROM [Order Details] GROUP BY [Order Details].OrderID;
Orders Qry→ orders_qry · 1 run · 830 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
- New SQL
SELECT … FROM orders_qry
SELECT DISTINCTROW Orders.OrderID, Orders.CustomerID, Orders.EmployeeID, Orders.OrderDate, Orders.RequiredDate, Orders.ShippedDate, Orders.ShipVia, Orders.Freight, Orders.ShipName, Orders.ShipAddress, Orders.ShipCity, Orders.ShipRegion, Orders.ShipPostalCode, Orders.ShipCountry, Customers.CompanyName, Customers.Address, Customers.City, Customers.Region, Customers.PostalCode, Customers.Country FROM Customers INNER JOIN Orders ON Customers.CustomerID = Orders.CustomerID;
Product Sales for 1995→ product_sales_for_1995 · 1 run · 77 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
- New SQL
SELECT … FROM product_sales_for_1995- Run on the original with
- A1 Two-digit year literals written with four digits (
#1/1/95#→#1/1/1995#). UCanAccess rejects the short form; Access reads 95 as 1995. - Discount arithmetic
- 10 of 77 rows would differ by one cent or more under Access's floating-point arithmetic (D1); the Access SQL comparison uses the control copy.
SELECT DISTINCTROW Categories.CategoryName, Products.ProductName, Sum(CCur([Order Details].[UnitPrice]*[Quantity]*(1-[Discount])/100)*100) AS ProductSales FROM (Categories INNER JOIN Products ON Categories.CategoryID = Products.CategoryID) INNER JOIN (Orders INNER JOIN [Order Details] ON Orders.OrderID = [Order Details].OrderID) ON Products.ProductID = [Order Details].ProductID WHERE (((Orders.ShippedDate) Between #1/1/95# And #12/31/95#)) GROUP BY Categories.CategoryName, Products.ProductName;
Products Above Average Price→ products_above_average_price · 1 run · 25 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
- New SQL
SELECT … FROM products_above_average_price- Order (unit_price)
- Same order of sort keys as Access SQL
SELECT DISTINCTROW Products.ProductName, Products.UnitPrice FROM Products WHERE (((Products.UnitPrice)>(SELECT AVG([UnitPrice]) From Products))) ORDER BY Products.UnitPrice DESC;
Products by Category→ products_by_category · 1 run · 69 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
- New SQL
SELECT … FROM products_by_category- Order (category_name, product_name)
- Same order of sort keys as Access SQL
SELECT DISTINCTROW Categories.CategoryName, Products.ProductName, Products.QuantityPerUnit, Products.UnitsInStock, Products.Discontinued FROM Categories INNER JOIN Products ON Categories.CategoryID = Products.CategoryID WHERE (((Products.Discontinued)<>Yes)) ORDER BY Categories.CategoryName, Products.ProductName;
Quarterly Orders→ quarterly_orders · 1 run · 84 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
- New SQL
SELECT … FROM quarterly_orders- Run on the original with
- A1 Two-digit year literals written with four digits (
#1/1/95#→#1/1/1995#). UCanAccess rejects the short form; Access reads 95 as 1995.
SELECT DISTINCTROW Customers.CustomerID, Customers.CompanyName, Customers.City, Customers.Country FROM Customers RIGHT JOIN Orders ON Customers.CustomerID = Orders.CustomerID WHERE (((Orders.OrderDate) Between #1/1/95# And #12/31/95#));
Quarterly Orders by Product→ quarterly_orders_by_product · 1 run · 914 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
- New SQL
SELECT … FROM quarterly_orders_by_product- Run on the original with
- A1 Two-digit year literals written with four digits (
#1/1/95#→#1/1/1995#). UCanAccess rejects the short form; Access reads 95 as 1995.
A5 Crosstab: UCanAccess has noTRANSFORM … PIVOT. We run the crosstab's grouping (row headings and pivot value, same aggregate) and pivot the rows into the four fixed column headings. - Discount arithmetic
- 12 of 914 rows would differ by one cent or more under Access's floating-point arithmetic (D1); the Access SQL comparison uses the control copy.
TRANSFORM Sum(CCur([Order Details].[UnitPrice]*[Quantity]*(1-[Discount])/100)*100) AS ProductAmount
SELECT DISTINCTROW Products.ProductName, Orders.CustomerID, Year([OrderDate]) AS OrderYear
FROM 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/95# And #12/31/95#))
GROUP BY Products.ProductName, Orders.CustomerID, Year([OrderDate])
PIVOT "Qtr " & DatePart("q",[OrderDate],1,0) In ("Qtr 1","Qtr 2","Qtr 3","Qtr 4");Sales by Category→ sales_by_category · 1 run · 77 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
- New SQL
SELECT … FROM sales_by_category- Order (product_name)
- Same order of sort keys as Access SQL
- Run on the original with
- A1 Two-digit year literals written with four digits (
#1/1/95#→#1/1/1995#). UCanAccess rejects the short form; Access reads 95 as 1995. - Discount arithmetic
- 10 of 77 rows would differ by one cent or more under Access's floating-point arithmetic (D1); the Access SQL comparison uses the control copy.
SELECT DISTINCTROW Categories.CategoryID, Categories.CategoryName, Products.ProductName, Sum([Order Details Extended].ExtendedPrice) AS ProductSales FROM Categories INNER JOIN (Products INNER JOIN (Orders INNER JOIN [Order Details Extended] ON Orders.OrderID = [Order Details Extended].OrderID) ON Products.ProductID = [Order Details Extended].ProductID) ON Categories.CategoryID = Products.CategoryID WHERE (((Orders.OrderDate) Between #1/1/95# And #12/31/95#)) GROUP BY Categories.CategoryID, Categories.CategoryName, Products.ProductName ORDER BY Products.ProductName;
Sales by Year→ sales_by_year · 3 runs · 1,299 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
- New SQL
SELECT … FROM sales_by_yearWHERE shipped_date BETWEEN ? AND ?- Parameter values
- 1995-01-01 .. 1995-12-31; 1994-01-01 .. 1996-12-31; 1995-07-01 .. 1995-09-30
- Run on the original with
- A2 Parameter query: the
PARAMETERSclause or form reference is replaced by literal values, one run per value set. The new view receives the same values through aWHEREclause. - Discount arithmetic
- 28 of 1,299 rows would differ by one cent or more under Access's floating-point arithmetic (D1); the Access SQL comparison uses the control copy.
PARAMETERS Forms!Sales by Year Dialog!BeginningDate DateTime, Forms!Sales by Year Dialog!EndingDate DateTime; SELECT DISTINCTROW Orders.ShippedDate, Orders.OrderID, [Order Subtotals].Subtotal, Format([ShippedDate],"yyyy") AS Year FROM Orders INNER JOIN [Order Subtotals] ON Orders.OrderID = [Order Subtotals].OrderID WHERE (((Orders.ShippedDate) Is Not Null And (Orders.ShippedDate) Between [Forms]![Sales by Year Dialog]![BeginningDate] And [Forms]![Sales by Year Dialog]![EndingDate]));
Sales Totals by Amount→ sales_totals_by_amount · 1 run · 61 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
- New SQL
SELECT … FROM sales_totals_by_amount- Run on the original with
- A1 Two-digit year literals written with four digits (
#1/1/95#→#1/1/1995#). UCanAccess rejects the short form; Access reads 95 as 1995. - Discount arithmetic
- 4 of 61 rows would differ by one cent or more under Access's floating-point arithmetic (D1); the Access SQL comparison uses the control copy.
SELECT DISTINCTROW [Order Subtotals].Subtotal AS SaleAmount, Orders.OrderID, Customers.CompanyName, Orders.ShippedDate FROM Customers INNER JOIN (Orders INNER JOIN [Order Subtotals] ON Orders.OrderID = [Order Subtotals].OrderID) ON Customers.CustomerID = Orders.CustomerID WHERE ((([Order Subtotals].Subtotal)>2500) AND ((Orders.ShippedDate) Between #1/1/95# And #12/31/95#));
Summary of Sales by Quarter→ summary_of_sales_by_quarter · 1 run · 809 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
- New SQL
SELECT … FROM summary_of_sales_by_quarter- Order (shipped_date)
- Same order of sort keys as Access SQL
- Discount arithmetic
- 15 of 809 rows would differ by one cent or more under Access's floating-point arithmetic (D1); the Access SQL comparison uses the control copy.
SELECT DISTINCTROW Orders.ShippedDate, Orders.OrderID, [Order Subtotals].Subtotal FROM Orders INNER JOIN [Order Subtotals] ON Orders.OrderID = [Order Subtotals].OrderID WHERE (((Orders.ShippedDate) Is Not Null)) ORDER BY Orders.ShippedDate;
Summary of Sales by Year→ summary_of_sales_by_year · 1 run · 809 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
- New SQL
SELECT … FROM summary_of_sales_by_year- Order (shipped_date)
- Same order of sort keys as Access SQL
- Discount arithmetic
- 15 of 809 rows would differ by one cent or more under Access's floating-point arithmetic (D1); the Access SQL comparison uses the control copy.
SELECT DISTINCTROW Orders.ShippedDate, Orders.OrderID, [Order Subtotals].Subtotal FROM Orders INNER JOIN [Order Subtotals] ON Orders.OrderID = [Order Subtotals].OrderID WHERE (((Orders.ShippedDate) Is Not Null)) ORDER BY Orders.ShippedDate;
Ten Most Expensive Products→ ten_most_expensive_products · 1 run · 10 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
- New SQL
SELECT … FROM ten_most_expensive_products- Order (unit_price)
- Same order of sort keys as Access SQL
- Run on the original with
- A4
TOP 10restored. Access stores it as a query property; the SQL text rebuilt by Jackcess leaves it out, so we read the property from the system table.
A6DISTINCTROWremoved beforeTOP, which UCanAccess cannot parse. On a single-table query it has no effect in Access.
SELECT DISTINCTROW TOP 10 Products.ProductName AS TenMostExpensiveProducts, Products.UnitPrice FROM Products ORDER BY Products.UnitPrice DESC;
Calculations: 5 of 5 equal
The figures printed by the Access reports (invoice subtotal, freight and total; sales by category; sales by year and quarter) come from these calculations. The web app reads the same views.
| Calculation | Rows | Python model | Access SQL | PostgreSQL | Browser | Rows affected by D1 | Result |
|---|---|---|---|---|---|---|---|
| Order totals (lines, subtotal after discount, freight, total) | 830 | ✓ | ✓ | ✓ | ✓ | 17 | Pass |
| Invoice report figures (from the Invoices query: subtotal, freight, total per order) | 830 | ✓ | ✓ | ✓ | ✓ | 17 | Pass |
| Sales by category and year (order date) | 24 | ✓ | ✓ | ✓ | ✓ | 12 | Pass |
| Sales by year and quarter (shipped date) | 8 | ✓ | ✓ | ✓ | ✓ | 7 | Pass |
| Grand total of all order lines | 1 | ✓ | ✓ | ✓ | ✓ | 1 | Pass |
Grand total of the 2,155 order lines: $1,265,793.02 in the new databases, $1,265,792.86 with the original floating-point arithmetic (difference $0.16, explained in D1).
Constraints: 17 of 17 enforced
The validation rules, required fields, field sizes and relationships of the Access file are now constraints in the database itself, so they hold for any program that writes to it, not only for the forms. Each statement below was sent to SQLite and to PostgreSQL inside a transaction that was then rolled back.
| Attempt | SQLite answer | Refused or applied as in Access (SQLite and PostgreSQL) |
|---|---|---|
| Order line with quantity 0 (Access rule >0) | CHECK constraint failed: quantity > 0 | ✓ |
| Discount above 100% (Access rule Between 0 And 1) | CHECK constraint failed: discount BETWEEN 0 AND 1 | ✓ |
| Negative unit price (Access rule >=0) | CHECK constraint failed: unit_price >= 0 | ✓ |
| Order for a customer that does not exist (relationship) | FOREIGN KEY constraint failed | ✓ |
| Order line for a product that does not exist (relationship) | FOREIGN KEY constraint failed | ✓ |
| Duplicate product on the same order (primary key) | UNIQUE constraint failed: order_details.order_id, order_details.product_id | ✓ |
| Delete a product that has order lines (relationship) | FOREIGN KEY constraint failed | ✓ |
| Birth date in the future (Access rule <Date()) | Birth date can't be in the future. | ✓ |
| Impossible date 31/02 (date type) | CHECK constraint failed: order_date IS NULL OR (date(order_date) IS NOT NULL AND date(order_date) = order_date) | ✓ |
| Product name missing (required) | NOT NULL constraint failed: products.product_name | ✓ |
| Company name longer than 40 characters (field size) | CHECK constraint failed: length(company_name) <= 40 | ✓ |
| Price with fractions of a cent | CHECK constraint failed: unit_price = round(unit_price, 2) | ✓ |
| Customer code in lower case | CHECK constraint failed: length(customer_id) = 5 AND customer_id = upper(customer_id) | ✓ |
| Employee reporting to himself | CHECK constraint failed: reports_to IS NULL OR reports_to <> employee_id | ✓ |
| Writing to the read-only archive | legacy_umsaetze is a read-only archive | ✓ |
| Deleting an order deletes its 3 lines (Access: cascade delete) | ✓ | |
| Renaming a customer code updates its 6 orders (Access: cascade update) | ✓ |
Intentional differences
D1. Discounts are exact percentages; 18 order lines change by one cent
Access evaluates order lines (price × quantity × (1 − discount), rounded to the cent by CCur) in binary floating point. The discount itself is a single-precision number, so 15% is held as 0.15000000596… and 5% as 0.05000000074…, and intermediate amounts such as 9.21375 cannot be held exactly either. On a line whose exact amount falls on a half cent, these tiny errors decide which cent Access shows. The new databases store the discount as the exact percentage and compute every line in whole numbers, with the same round-half-to-even rule that Access applies to an exact half. The result is identical in SQLite, PostgreSQL and the browser.
Compared with our model of the Access arithmetic (double precision, then CCur as implemented by OLE Automation: × 10,000 and round half to even), 18 of 2,155 lines on 17 orders differ by exactly 0.01. The grand total moves from $1,265,792.86 to $1,265,793.02. For lines this close to a half cent the exact Access result depends on the Jet engine's internal order of operations, which cannot be run outside Windows; the new rule removes that dependence.
| Order | Product | Unit price | Qty | Discount | Stored by Access | Exact amount | Access | New |
|---|---|---|---|---|---|---|---|---|
| 10324 | 16 | $13.90 | 21 | 15% | 0.150000005960… | 248.115 | $248.11 | $248.12 |
| 10403 | 16 | $13.90 | 21 | 15% | 0.150000005960… | 248.115 | $248.11 | $248.12 |
| 10429 | 63 | $35.10 | 35 | 25% | 0.25 | 921.375 | $921.37 | $921.38 |
| 10440 | 16 | $13.90 | 49 | 15% | 0.150000005960… | 578.935 | $578.93 | $578.94 |
| 10549 | 31 | $12.50 | 55 | 15% | 0.150000005960… | 584.375 | $584.37 | $584.38 |
| 10605 | 71 | $21.50 | 15 | 5% | 0.050000000745… | 306.375 | $306.37 | $306.38 |
| 10616 | 38 | $263.50 | 15 | 5% | 0.050000000745… | 3754.875 | $3,754.87 | $3,754.88 |
| 10616 | 71 | $21.50 | 15 | 5% | 0.050000000745… | 306.375 | $306.37 | $306.38 |
| 10648 | 24 | $4.50 | 15 | 15% | 0.150000005960… | 57.375 | $57.37 | $57.38 |
| 10656 | 14 | $23.25 | 3 | 10% | 0.100000001490… | 62.775 | $62.77 | $62.78 |
| 10668 | 64 | $33.25 | 15 | 10% | 0.100000001490… | 448.875 | $448.87 | $448.88 |
| 10721 | 44 | $19.45 | 50 | 5% | 0.050000000745… | 923.875 | $923.87 | $923.88 |
| 10730 | 65 | $21.05 | 10 | 5% | 0.050000000745… | 199.975 | $199.97 | $199.98 |
| 10776 | 45 | $9.50 | 27 | 5% | 0.050000000745… | 243.675 | $243.67 | $243.68 |
| 10800 | 54 | $7.45 | 7 | 10% | 0.100000001490… | 46.935 | $46.93 | $46.94 |
| 10978 | 44 | $19.45 | 6 | 15% | 0.150000005960… | 99.195 | $99.19 | $99.20 |
| 11070 | 16 | $17.45 | 30 | 15% | 0.150000005960… | 444.975 | $444.97 | $444.98 |
| 11077 | 64 | $33.25 | 2 | 3% | 0.029999999329… | 64.505 | $64.51 | $64.50 |
D2. Text clean-up (148 values)
- C1, spaces at the start or end removed (41 values), for example
Lino Rodriguez,Frankfurt a.M.,Antonio del Valle Saavedra,Sweden. One of them,Sweden, would otherwise appear as a separate country in any grouping. Double spaces inside a value were left as they are. - C2, line breaks stored as LF instead of CR LF (114 values, in two-line addresses and notes). The text is the same; only the end-of-line characters change.
Values per table: customers 10, employees 3, legacy_umsaetze 62, orders 62, suppliers 11. Every change is listed, value by value, in db/migration_log.json. The ship name Galería del gastronómo on orders differs from the customer name Galería del gastrónomo; it is a copy made when the order was entered, so it was not corrected.
Other conversions (no value changed)
- Dates. Access date/time fields became
date. No value in the file has a time part, which the migration verifies. - Pictures. Paintbrush bitmaps wrapped in Access OLE objects became PNG files, pixel for pixel; the original objects are archived unchanged.
- Yes/No fields became booleans (Access stores Yes as −1).
- Supplier home pages keep the Access hyperlink text (
label#address#) unchanged; the application shows the label and the address. - Row order. Views keep the
ORDER BYof the Access query. SQLite and PostgreSQL sort text by character code here; on this data the order of the sort keys is the same as in the Access SQL run. The web app sorts lists with the browser's language rules.
Tool findings
- UCanAccess 5.1.3 drops Access 97 discounts. It creates the
Discountcolumn (Single) asNUMERIC(100,0)in its in-memory copy, so all 838 non-zero discounts are read as 0. The table cross-check found it: every other column of every table matches. We kept UCanAccess as the engine for the Access SQL text and compared it with a control copy of the new database in which the discounts are 0. - The SQL text rebuilt by Jackcess omits
TOP 10in Ten Most Expensive Products. The value is a query property in the system tableMSysQueries; we read it from there (A4). - UCanAccess could not load nine of the 22 saved queries as views (two-digit years, parameters, the crosstab, duplicate output names). Each was run from its SQL text with the adaptations below. The SQL logic itself was not rewritten.
| Code | Adaptation used only to run the original SQL on UCanAccess |
|---|---|
| A1 | Two-digit year literals written with four digits (#1/1/95# → #1/1/1995#). UCanAccess rejects the short form; Access reads 95 as 1995. |
| A2 | Parameter query: the PARAMETERS clause or form reference is replaced by literal values, one run per value set. The new view receives the same values through a WHERE clause. |
| A3 | A saved query that UCanAccess could not load is written inline as a derived table (the SQL text is the saved one). |
| A4 | TOP 10 restored. Access stores it as a query property; the SQL text rebuilt by Jackcess leaves it out, so we read the property from the system table. |
| A5 | Crosstab: UCanAccess has no TRANSFORM … PIVOT. We run the crosstab's grouping (row headings and pivot value, same aggregate) and pivot the rows into the four fixed column headings. |
| A6 | DISTINCTROW removed before TOP, which UCanAccess cannot parse. On a single-table query it has no effect in Access. |
Type mapping
Names follow the usual SQL convention (OrderDate → order_date). SQLite tables are declared STRICT, so a column refuses a value of the wrong type.
| Access | SQLite | PostgreSQL | Columns |
|---|---|---|---|
| LONG (AutoNumber) | INTEGER PRIMARY KEY (rowid alias, next = max+1) | integer GENERATED BY DEFAULT AS IDENTITY | 7 |
| LONG | INTEGER | integer | 9 |
| INT (16-bit) | INTEGER + CHECK range | smallint | 4 |
| TEXT(5) key | TEXT + CHECK length | varchar(5) | 3 |
| TEXT(n) | TEXT + CHECK(length <= n) | varchar(n) | 48 |
| MEMO | TEXT | text | 3 |
| MONEY (Currency, 4 decimals) | REAL (all values have at most 2 decimals; amounts computed in integer cents) | numeric(19,4) | 4 |
| FLOAT (Single, 32-bit) | REAL holding the exact decimal fraction | numeric(5,4) | 1 |
| DATETIME (no time part in any row) | TEXT 'YYYY-MM-DD' + CHECK date(x)=x | date | 8 |
| BOOLEAN (Yes=-1/No=0) | INTEGER 0/1 + CHECK | boolean | 1 |
| OLE Object (Paintbrush bitmap) | BLOB (PNG) | bytea (PNG) | 2 |
All 90 columns, one by one
| Access column | New column | Access type | PostgreSQL type | Rules |
|---|---|---|---|---|
| Categories.CategoryID | categories.category_id | LONG (AutoNumber) | integer GENERATED BY DEFAULT AS IDENTITY | required; primary key |
| Categories.CategoryName | categories.category_name | TEXT(15) | varchar(15) | required |
| Categories.Description | categories.description | MEMO | text | |
| Categories.Picture | categories.picture | OLE Object (Paintbrush bitmap) | bytea (PNG) | |
| Customers.CustomerID | customers.customer_id | TEXT(5) key | varchar(5) | required; primary key; New rule from the field description ("unique five-character code"); Access compares text case-insensitively, so codes are kept upper-case to keep them unique in the same way. All 91 rows comply. |
| Customers.CompanyName | customers.company_name | TEXT(40) | varchar(40) | required |
| Customers.ContactName | customers.contact_name | TEXT(30) | varchar(30) | |
| Customers.ContactTitle | customers.contact_title | TEXT(30) | varchar(30) | |
| Customers.Address | customers.address | TEXT(60) | varchar(60) | |
| Customers.City | customers.city | TEXT(15) | varchar(15) | |
| Customers.Region | customers.region | TEXT(15) | varchar(15) | |
| Customers.PostalCode | customers.postal_code | TEXT(10) | varchar(10) | |
| Customers.Country | customers.country | TEXT(15) | varchar(15) | |
| Customers.Phone | customers.phone | TEXT(24) | varchar(24) | |
| Customers.Fax | customers.fax | TEXT(24) | varchar(24) | |
| Employees.EmployeeID | employees.employee_id | LONG (AutoNumber) | integer GENERATED BY DEFAULT AS IDENTITY | required; primary key |
| Employees.LastName | employees.last_name | TEXT(20) | varchar(20) | required |
| Employees.FirstName | employees.first_name | TEXT(10) | varchar(10) | required |
| Employees.Title | employees.title | TEXT(30) | varchar(30) | |
| Employees.TitleOfCourtesy | employees.title_of_courtesy | TEXT(25) | varchar(25) | Access lookup value list Dr.;Mr.;Miss;Mrs.;Ms. (not enforced by Access); all 9 rows comply |
| Employees.BirthDate | employees.birth_date | DATETIME (no time part in any row) | date | Access validation rule <Date() ("Birth date can't be in the future.") -> trigger, because CHECK cannot use the current date |
| Employees.HireDate | employees.hire_date | DATETIME (no time part in any row) | date | |
| Employees.Address | employees.address | TEXT(60) | varchar(60) | |
| Employees.City | employees.city | TEXT(15) | varchar(15) | |
| Employees.Region | employees.region | TEXT(15) | varchar(15) | |
| Employees.PostalCode | employees.postal_code | TEXT(10) | varchar(10) | |
| Employees.Country | employees.country | TEXT(15) | varchar(15) | |
| Employees.HomePhone | employees.home_phone | TEXT(24) | varchar(24) | |
| Employees.Extension | employees.extension | TEXT(4) | varchar(4) | |
| Employees.Photo | employees.photo | OLE Object (Paintbrush bitmap) | bytea (PNG) | |
| Employees.Notes | employees.notes | MEMO | text | |
| Employees.ReportsTo | employees.reports_to | LONG | integer | → employees; New foreign key: Access had only a lookup, no relationship. All 9 rows comply. |
| Shippers.ShipperID | shippers.shipper_id | LONG (AutoNumber) | integer GENERATED BY DEFAULT AS IDENTITY | required; primary key |
| Shippers.CompanyName | shippers.company_name | TEXT(40) | varchar(40) | required |
| Shippers.Phone | shippers.phone | TEXT(24) | varchar(24) | |
| Suppliers.SupplierID | suppliers.supplier_id | LONG (AutoNumber) | integer GENERATED BY DEFAULT AS IDENTITY | required; primary key |
| Suppliers.CompanyName | suppliers.company_name | TEXT(40) | varchar(40) | required |
| Suppliers.ContactName | suppliers.contact_name | TEXT(30) | varchar(30) | |
| Suppliers.ContactTitle | suppliers.contact_title | TEXT(30) | varchar(30) | |
| Suppliers.Address | suppliers.address | TEXT(60) | varchar(60) | |
| Suppliers.City | suppliers.city | TEXT(15) | varchar(15) | |
| Suppliers.Region | suppliers.region | TEXT(15) | varchar(15) | |
| Suppliers.PostalCode | suppliers.postal_code | TEXT(10) | varchar(10) | |
| Suppliers.Country | suppliers.country | TEXT(15) | varchar(15) | |
| Suppliers.Phone | suppliers.phone | TEXT(24) | varchar(24) | |
| Suppliers.Fax | suppliers.fax | TEXT(24) | varchar(24) | |
| Suppliers.HomePage | suppliers.home_page | MEMO | text | |
| Products.ProductID | products.product_id | LONG (AutoNumber) | integer GENERATED BY DEFAULT AS IDENTITY | required; primary key |
| Products.ProductName | products.product_name | TEXT(40) | varchar(40) | required |
| Products.SupplierID | products.supplier_id | LONG | integer | → suppliers |
| Products.CategoryID | products.category_id | LONG | integer | → categories |
| Products.QuantityPerUnit | products.quantity_per_unit | TEXT(20) | varchar(20) | |
| Products.UnitPrice | products.unit_price | MONEY (Currency, 4 decimals) | numeric(19,4) | Access validation rule >=0 ("You must enter a positive number.") |
| Products.UnitsInStock | products.units_in_stock | INT (16-bit) | smallint | Access validation rule >=0 ("You must enter a positive number.") |
| Products.UnitsOnOrder | products.units_on_order | INT (16-bit) | smallint | Access validation rule >=0 ("You must enter a positive number.") |
| Products.ReorderLevel | products.reorder_level | INT (16-bit) | smallint | Access validation rule >=0 ("You must enter a positive number.") |
| Products.Discontinued | products.discontinued | BOOLEAN (Yes=-1/No=0) | boolean | required |
| Orders.OrderID | orders.order_id | LONG (AutoNumber) | integer GENERATED BY DEFAULT AS IDENTITY | required; primary key |
| Orders.CustomerID | orders.customer_id | TEXT(5) key | varchar(5) | → customers |
| Orders.EmployeeID | orders.employee_id | LONG | integer | → employees |
| Orders.OrderDate | orders.order_date | DATETIME (no time part in any row) | date | |
| Orders.RequiredDate | orders.required_date | DATETIME (no time part in any row) | date | |
| Orders.ShippedDate | orders.shipped_date | DATETIME (no time part in any row) | date | |
| Orders.ShipVia | orders.ship_via | LONG | integer | → shippers |
| Orders.Freight | orders.freight | MONEY (Currency, 4 decimals) | numeric(19,4) | New rule (no negative freight); all 830 rows comply |
| Orders.ShipName | orders.ship_name | TEXT(40) | varchar(40) | |
| Orders.ShipAddress | orders.ship_address | TEXT(60) | varchar(60) | |
| Orders.ShipCity | orders.ship_city | TEXT(15) | varchar(15) | |
| Orders.ShipRegion | orders.ship_region | TEXT(15) | varchar(15) | |
| Orders.ShipPostalCode | orders.ship_postal_code | TEXT(10) | varchar(10) | |
| Orders.ShipCountry | orders.ship_country | TEXT(15) | varchar(15) | |
| Order Details.OrderID | order_details.order_id | LONG | integer | required; primary key; → orders |
| Order Details.ProductID | order_details.product_id | LONG | integer | required; primary key; → products |
| Order Details.UnitPrice | order_details.unit_price | MONEY (Currency, 4 decimals) | numeric(19,4) | required; Access validation rule >=0 ("You must enter a positive number.") |
| Order Details.Quantity | order_details.quantity | INT (16-bit) | smallint | required; Access validation rule >0 ("Quantity must be greater than 0") |
| Order Details.Discount | order_details.discount | FLOAT (Single, 32-bit) | numeric(5,4) | required; Access validation rule Between 0 And 1 |
| Umsätze.OrderID | legacy_umsaetze.order_id | LONG (AutoNumber) | integer GENERATED BY DEFAULT AS IDENTITY | required; primary key |
| Umsätze.CustomerID | legacy_umsaetze.customer_id | TEXT(5) key | varchar(5) | |
| Umsätze.EmployeeID | legacy_umsaetze.employee_id | LONG | integer | |
| Umsätze.OrderDate | legacy_umsaetze.order_date | DATETIME (no time part in any row) | date | |
| Umsätze.RequiredDate | legacy_umsaetze.required_date | DATETIME (no time part in any row) | date | |
| Umsätze.ShippedDate | legacy_umsaetze.shipped_date | DATETIME (no time part in any row) | date | |
| Umsätze.ShipVia | legacy_umsaetze.ship_via | LONG | integer | |
| Umsätze.Freight | legacy_umsaetze.freight | MONEY (Currency, 4 decimals) | numeric(19,4) | |
| Umsätze.ShipName | legacy_umsaetze.ship_name | TEXT(40) | varchar(40) | |
| Umsätze.ShipAddress | legacy_umsaetze.ship_address | TEXT(60) | varchar(60) | |
| Umsätze.ShipCity | legacy_umsaetze.ship_city | TEXT(15) | varchar(15) | |
| Umsätze.ShipRegion | legacy_umsaetze.ship_region | TEXT(15) | varchar(15) | |
| Umsätze.ShipPostalCode | legacy_umsaetze.ship_postal_code | TEXT(10) | varchar(10) | |
| Umsätze.ShipCountry | legacy_umsaetze.ship_country | TEXT(15) | varchar(15) |
Reproduce
Everything above is produced by scripts; nothing in this report is typed by hand. From the case-study folder:
python3 migration/build_db.py # migrate: db/northwind.sqlite, PostgreSQL scripts, migration log python3 migration/equivalence.py # all comparisons (about 89 s) python3 migration/make_report.py # this page and results.json
Delivered files: db/schema.sqlite.sql, db/views.sqlite.sql, db/schema.postgresql.sql, db/data.postgresql.sql, db/views.postgresql.sql, db/parameterized.sql, db/northwind.sqlite (SHA-256 525cdfd6a4eb016f…, 1,142,784 bytes).