NAME
App::Access2CSV::Exporter - Export the tables of a Microsoft Access database to CSV files
VERSION
Version 0.001.0
SYNOPSIS
use App::Access2CSV::Exporter;
# 1. The simplest case: every table, into the current folder
my $exporter = App::Access2CSV::Exporter->new();
my $status = $exporter->run('shop.accdb'); # 0 = all OK, 1 = some failed
# 2. Some tables, into a folder, for Excel, replacing old files
my $exporter = App::Access2CSV::Exporter->new(
output_dir => 'exports',
tables => ['Customers', 'Orders'],
encoding => 'utf8-bom',
overwrite => 1,
);
$exporter->run('shop.accdb');
# 3. Only look: print the table list and row counts, write nothing
App::Access2CSV::Exporter->new(dry_run => 1, show_counts => 1)->run('shop.accdb');
# 4. Inside a larger program: no progress lines, a log, and full
# error handling
use Log::Abstraction;
my $exporter = App::Access2CSV::Exporter->new({
output_dir => '/srv/exports',
progress => 0,
logger => Log::Abstraction->new(logger => '/var/log/export.log'),
});
my $status = eval { $exporter->run('/data/shop.accdb') };
if(!defined $status) {
die "Nothing was exported: $@"; # for example, the file is missing
} elsif($status == 1) {
warn "Some tables were not exported; see the log\n";
}
DESCRIPTION
This module does the real work of the access2csv program. It writes one CSV file for each table of a Microsoft Access database.
It runs three programs from the mdbtools package: mdb-tables (to list the tables), mdb-export (to get each table as CSV) and, only when row counts are wanted, mdb-count. They must be in your PATH.
Access's own internal tables (names starting with MSys, USys or ~) are skipped.
Each file is first written to a hidden temporary file in the output folder, and renamed to its real name only when it is complete. So a failed export never leaves a half-written CSV file, and an old file is only replaced by a complete new one. New files get the usual permissions (0666 minus your umask).
An exporter can be used for more than one run. Each run starts again with the same file names, so running twice gives the same files.
The mdbtools programs are looked up only in absolute PATH folders, so a program planted in the current folder is never run, and they are started with a cleaned environment (see "SECURITY" in App::Access2CSV). The module works under taint mode (perl -T). Table names are printed and logged with control characters escaped, so a hostile name cannot send escape sequences to your terminal.
Table names and the database path are handed to mdbtools as separate arguments, never through a shell, and after a -- marker. So names containing shell characters (; | > $( )), spaces or newlines, or starting with -, are always treated as names, never as commands or options. A table name can never place a file outside the output folder: / and \ are replaced, and names cannot start with a dot.
An existing entry at the target name - including a symbolic link, even a broken one - counts as "already exists". With overwrite, the link itself is replaced; the file it pointed to is never written.
The rules for file names are described in "How the CSV files are named" in App::Access2CSV.
ENCODING
CSV data. mdbtools gives UTF-8. With
encodingset toutf8orutf8-bomthe bytes are copied exactly, so every character, including emoji and non-Latin scripts, is kept.utf8-bomalso writes the three-byte UTF-8 "byte order mark" first, which Microsoft Excel needs. Withcp1252, each line is converted to Windows-1252; if a line has a character that Windows-1252 does not have (for example Greek, Chinese or an emoji), that table fails and nothing is written for it.Database path and output_dir. These are passed to the operating system unchanged. Give them as byte strings (the form you get from
@ARGVorreaddir). Non-ASCII names work on systems whose file names are UTF-8, such as Linux and macOS.Table names (in
tables). They are compared with the names thatmdb-tablesprints, which are UTF-8 bytes. So give UTF-8 byte strings, not decoded Perl character strings. If you have a decoded string, useEncode::encode('UTF-8', $name)first. The CSV file name is made from the same bytes, so non-ASCII names and emoji are kept.Messages. All messages are plain ASCII English.
COMMON PITFALLS
undef means "use the default", not "false". In
new,overwrite => undefis the same as not givingoverwriteat all. To switch something off, give0.An empty table list exports nothing.
tables => undef(or notables) means "all tables".tables => []means "no tables": nothing is exported, andrunreturns 0.Table names are case-sensitive.
'orders'does not match the tableOrders. Names that do not match any table give a warning.A failing logger does not stop the export. If the logger dies (for example, its disk is full),
runwarns once with "Cannot write to the log", stops logging for this exporter, and carries on exporting.run can croak.
runreturns 1 when some tables fail, but it croaks (throws an exception) when nothing can be exported at all: the database is missing or unreadable, mdbtools is not installed, or the output folder cannot be created. Wrapruninevalif your program must keep going.run may change the show_counts setting. If
show_countsis on butmdb-countcannot be found,runwarns and switchesshow_countsoff for this exporter.Settings are copied, not shared.
newmakes its own copy of thetableslist; changing your array later has no effect. Settings are not merged in depth: a newtableslist replaces the default completely.Warnings go through carp. Failed tables and unknown table names are reported with
carp, so they appear on standard error (or in your$SIG{__WARN__}handler) even when a logger is given.Load the module with use, not require. Protection of the private methods is set up at compile time. After a run-time
requirePerl prints "Too late to run CHECK block" and the protection is missing.
METHODS
new
Purpose
Make a new exporter with your settings. Nothing is checked on disk yet.
Arguments
Named arguments, either as a list or as one hash reference. All of them are optional. An argument whose value is undef is ignored, so its default is used.
output_dir- the folder for the CSV files. Default: the current folder.tables- an array reference of table names to export. Default: all tables. An empty array means no tables.overwrite- true to replace CSV files that already exist. Default: false.verbose- true to log extra detail. Default: false.dry_run- true to only print what would be written. Default: false.show_counts- true to report row counts (needsmdb-count). Default: false.progress- true to print[n/total] tablelines to standard error. Default: true.encoding-utf8,utf8-bomorcp1252. Default:utf8.logger- an object withdebug,infoandwarnmethods, such as a Log::Abstraction object. Default: no logging.language- a language code such asenfor messages. Default: taken from the locale (see App::Access2CSV::I18N).
Returns
A new App::Access2CSV::Exporter object.
Side Effects
None. Your $@, $! and $_ are left as they were.
Usage
my $exporter = App::Access2CSV::Exporter->new({ dry_run => 1 });
EXAMPLE
# Export two tables as Windows-1252, replacing older files, with a log
my $exporter = App::Access2CSV::Exporter->new(
output_dir => 'out',
tables => ['Customers', 'Orders'],
encoding => 'cp1252',
overwrite => 1,
logger => Log::Abstraction->new(logger => 'export.log'),
);
API SPECIFICATION
Input
{
output_dir => { type => 'string', min => 1, optional => 1 },
tables => { type => 'arrayref', element_type => 'string', optional => 1 },
overwrite => { type => 'boolean', optional => 1 },
verbose => { type => 'boolean', optional => 1 },
dry_run => { type => 'boolean', optional => 1 },
show_counts => { type => 'boolean', optional => 1 },
progress => { type => 'boolean', optional => 1 },
encoding => { type => 'string', memberof => ['utf8', 'utf8-bom', 'cp1252'], optional => 1 },
logger => { type => 'object', can => ['debug', 'info', 'warn'], optional => 1 },
language => { type => 'string', optional => 1 },
}
Valid and invalid values (tested in t/domain.t):
output_dir valid: any path of 1 character or more ("0" is valid),
including non-ASCII names given as UTF-8 bytes
invalid: "" (below the minimum), references
tables valid: undef (all tables), [] (no tables), one name,
many names; names are matched byte for byte
invalid: anything but an array reference; elements
that are references
booleans valid: exactly 1 true TRUE yes on / 0 false FALSE no off
(overwrite, invalid: everything else, e.g. "", 2, -1, "True", "Yes",
verbose, "0.0", " 1"
dry_run, show_counts, progress)
encoding valid: exactly utf8, utf8-bom, cp1252
invalid: other spellings (UTF8, utf-8, CP1252, " utf8")
logger valid: an object with debug, info and warn methods
invalid: an object missing any of them, a plain hash,
a string
language valid: any string; "" means "use the environment"
invalid: references
Output
{
type => 'object',
isa => 'App::Access2CSV::Exporter',
}
MESSAGES
All are fatal, and read "Invalid setting: REASON". The REASON part comes from Params::Validate::Strict and is not translated; for example:
+--------------------------------------+------------------------------+-----------------------------+
| Message | Meaning | What to do |
+--------------------------------------+------------------------------+-----------------------------+
| Invalid setting: Unknown parameter | X is not a known setting | Remove X, or fix its |
| 'X' | | spelling |
| Invalid setting: Parameter | This encoding is not | Use utf8, utf8-bom or |
| 'encoding' (X) must be one of utf8, | supported | cp1252 |
| utf8-bom, cp1252 | | |
| Invalid setting: Parameter 'logger' | logger is not an object | Give a logger object |
| must be an object | | |
| Invalid setting: Parameter 'tables' | tables is not an array | Give an array reference |
| must be ... | reference | |
+--------------------------------------+------------------------------+-----------------------------+
run
Purpose
Export the selected tables of one database to CSV files. In dry-run mode, only print what would be exported.
Arguments
database(string, required) - the path of the.mdbor.accdbfile. The name is taken literally:-is a file called-. (Reading from standard input is a feature of the command-line program; see "Reading the database from standard input" in App::Access2CSV.)
You can give it on its own, $exporter->run('shop.accdb'), or as a hash reference, $exporter->run({ database => 'shop.accdb' }).
Returns
0 if every selected table was exported, or in dry-run mode. 1 if at least one table was not exported (the others were).
Side Effects
Creates the output folder if needed (not in dry-run mode, and not when no table is selected).
Writes one CSV file per table (not in dry-run mode).
Prints progress lines to standard error, if
progressis on.Prints the dry-run list to standard output, in dry-run mode.
Sends messages to the logger, if there is one.
Warns (with
carp) about each table that failed, about unknown names intables, and about a missingmdb-count.Croaks, before writing anything, if the database cannot be read, a needed mdbtools program is missing,
mdb-tablesfails, or the output folder cannot be created.Switches
show_countsoff for this exporter ifmdb-countis missing.While it runs, handles the signals INT, QUIT, TERM and HUP (only those you have not set a handler for yourself; they are restored when
runreturns). Any of them stops the run: the table being exported is discarded - its temporary file deleted, any old CSV file left as it was - no further table is started, andruncroaks. Pressing Ctrl-C, which also stops the mdbtools program, has the same effect.Leaves your
$@,$!,$?,$_,$.and any pendingalarmas they were (except that a croak sets$@in youreval, as usual).
Usage
exit $exporter->run('shop.accdb');
EXAMPLE
my $exporter = App::Access2CSV::Exporter->new(output_dir => 'out');
# eval catches the fatal errors; the return value covers the rest
my $status = eval { $exporter->run('shop.accdb') };
if(!defined $status) {
print STDERR "Nothing was exported: $@";
} elsif($status) {
print STDERR "Some tables failed; see the warnings above\n";
} else {
print "Done\n";
}
API SPECIFICATION
Input
{
database => {
type => 'string',
min => 1,
optional => 0,
},
}
Valid and invalid values (tested in t/domain.t):
database valid: a readable regular file
invalid: "" (below the 1-character minimum), undef,
a missing file, a folder, a device or FIFO,
an unreadable file
edges: each part of the path may be up to 255 bytes;
256 gives "File name too long"
The table names that mdbtools reports are data, not arguments, but they have limits of their own:
length a CSV file name is the table name plus ".csv", and the
file system limits file names, so table names up to 251
units work and longer ones fail (that table only). The
unit depends on the file system: Linux counts bytes (125
u-umlauts, 2 bytes each, fit; 126 do not), macOS counts
characters (up to 251 of any letter fit). Access allows
at most 64 characters, well within either limit.
characters non-ASCII letters, emoji, joined emoji, combining marks
and right-to-left text are kept byte for byte.
Characters that are unsafe in file names - including
invisible text-direction controls such as U+202E - are
replaced by "_".
collisions the first name has no suffix, then _2, _3, ... _10 ...
cp1252 U+00FF and the Euro sign convert; U+0100 and above
(except the few Windows-1252 symbols), the C1 controls
U+0080-U+009F and emoji make the table fail.
Output
{
type => 'integer',
min => 0,
max => 1,
}
MESSAGES
"fatal" means run croaks and nothing is exported. "per table" means only that table fails; run warns, logs, and carries on.
+-----------------------------------------+------------------------------+-------------------------------+
| Message | Meaning | What to do |
+-----------------------------------------+------------------------------+-------------------------------+
| Interrupted by SIGx: stopped, and the | Ctrl-C, Ctrl-\\, kill or a | Run again; tables finished |
| table being exported was discarded | closed terminal stopped the | before the interruption are |
| (fatal) | run | complete |
| run() must be called on an object | run was called on the class | Call new() first, then run() |
| created by new() (fatal) | or on something that is not | on the object it returns |
| | an exporter | |
| Cannot read database F: E (fatal) | F does not exist, or cannot | Check the path |
| | be reached; E is the reason | |
| | from the operating system | |
| Database F is not a regular file (fatal)| F is a folder or a device | Give the database file |
| Database F is not readable (fatal) | No permission to read F | Fix the permissions |
| | (never happens for root) | |
| Required program not found in PATH: P | mdbtools is not installed, | Install mdbtools, or fix PATH |
| (fatal) | or not in PATH | |
| mdb-tables failed with exit status N: E | mdbtools cannot read the | Check that F is a real Access |
| (fatal) | file | database |
| Cannot create output directory D: E | The folder cannot be made; | Check permissions and path |
| (fatal) | E is the reason for D itself | |
| | (e.g. "Not a directory" when | |
| | a file is in the way) | |
| Cannot count the rows of T: E (warning) | mdb-count failed for table T | The table is still exported |
| | (only with show_counts) | (dry run: count shown as "?") |
| ... mdb-count printed no number: "X" | mdb-count's answer was not | As above; X shows what it |
| | just a number | printed |
| Tables not found in database: T | Names in tables are not in | Check spelling and case |
| (warning) | the database | |
| mdb-count not found in PATH; row counts | show_counts is on, but | Install mdb-count, or turn |
| are unavailable (warning) | mdb-count is missing | show_counts off |
| FAILED: T: E (warning, logged) | Table T was not exported, | See E, one of the messages |
| | because of E | below |
| Output file already exists: F (use | F exists (a symbolic link, | Set overwrite, or use another |
| --overwrite to replace it) (per table) | even a broken one, counts) | output_dir |
| | and overwrite is off | |
| mdb-export failed with exit status N: E | mdbtools could not read this | Check the table in Access |
| (per table) | table | |
| P was killed by signal N (per table, | The program was stopped from | Check memory and system |
| or fatal for mdb-tables) | outside | limits |
| P could not be run: E (per table, or | The program was found but | Check its permissions and |
| fatal for mdb-tables) | could not be started | that it is a real program |
| Table T, line N: cannot be represented | A character is not in | Use utf8 or utf8-bom |
| in cp1252 (per table) | Windows-1252 | |
| Table T, line N: output of mdb-export | mdbtools gave bytes that are | Check the MDB_ICONV setting |
| is not valid UTF-8 (per table) | not UTF-8 | |
| Cannot write F: E (per table) | The file could not be | Check permissions and free |
| | written or renamed into place| disk space |
| Cannot write to the log: E (warning, | The logger failed. Exports | Check the log's disk or |
| once) | go on; logging stops | destination |
+-----------------------------------------+------------------------------+-------------------------------+
PSEUDOCODE
check the argument
stop (croak) unless the database is a readable file
find mdb-tables and mdb-export (croak if missing),
and mdb-count if row counts are wanted (warn if missing)
forget the file names given out by any earlier run
tables := the sorted user tables, filtered by "tables"
(warn about names that are not found)
if dry run:
print the table -> file list (row count "?" with a warning
if a count fails)
return 0
if there are tables to export:
create the output folder (croak if that fails)
for each table:
print "[n/total] table" if progress is on
try to export the table
if that failed: warn, log, and count the failure
(a failed row count after the file is in place is only a
warning; the table still counts as exported)
log the summary
return 1 if any table failed, else 0
DESIGN NOTES
Some checks are done once, early, and deliberately not repeated later. The reasoning, in plain words:
Settings are checked once. Premise 1:
newrefuses any setting outside its documented values. Premise 2: settings cannot be changed through the API afterwards. Conclusion: the rest of the code can trust them; for example,encodingis always one of the three names, so no "unknown encoding" branch is needed.Fail fast, in a fixed order.
runchecks the database, then finds the programs, then lists the tables, then makes the folder. Premise 1: each step needs the one before it. Premise 2: a failure in one step is fatal. Conclusion: when a step fails, nothing after it runs - no program is looked up for a missing database, no program is run when one is missing, and no folder is made when the table list fails.A dry run always succeeds. Premise 1: a dry run writes no files. Premise 2: only writing a file can fail a table. Conclusion: a dry run returns 0 without reaching the export loop.
"Could not start" is tested before "killed by a signal". Premise 1: when a program cannot be started, the exit status
$?is -1. Premise 2: the signal number is$? & 127, and-1 & 127is 127. Conclusion: testing for a signal first would wrongly report signal 127.The overwrite setting is tested before the file. Premise 1: a table is refused only if overwrite is off and the name exists. Premise 2: when overwrite is on, the answer is already "go ahead". Conclusion: the file tests are skipped in that case.
LIMITATIONS
mdbtools is expected to give UTF-8. This is what it does when it is built with iconv (the normal case). If the
MDB_ICONVenvironment variable selects another character set,cp1252conversion reports invalid UTF-8.cp1252conversion stops at the first character that Windows-1252 does not have, and that table fails. It never writes?instead. Line numbers count lines in the file, so a text field that contains line breaks covers several lines.Checking that a file already exists and renaming the new file into place are two separate steps. If another program creates the same file between them, that file is replaced.
File names are made safe for Windows, macOS and Unix, but are not shortened. Access table names are at most 64 characters, which is well within normal limits.
Row counts need one extra
mdb-countrun for each table.When the program itself is sent SIGTERM or SIGHUP (not Ctrl-C), the mdbtools program it was running is not stopped: it runs to the end, writing only to the temporary file that has already been deleted. IPC::Run3 does not say which process it started, so it cannot be signalled.
The private and protected methods are protected by Sub::Private and Sub::Protected only when this module is loaded with
use. When$ENV{HARNESS_ACTIVE}is set (underprove), the checks are turned off so that tests can call these methods.
SEE ALSO
App::Access2CSV, App::Access2CSV::I18N, https://github.com/mdbtools/mdbtools
AUTHOR
Nigel Horne, <njh at nigelhorne.com>
LICENSE AND COPYRIGHT
Copyright 2026 Nigel Horne.
Usage is subject to the GPL2 licence terms. If you use it, please let me know.
FORMAL SPECIFICATION
These schemas use the Z notation. ? marks an input, ! an output, ' the state after the operation, "Delta" a changed state and "Xi" an unchanged state. You do not need to read this section to use the module.
┌─ Exporter ─────────────────────────────────────────────────
│ settings : SETTING ⇸ VALUE
│ used_names : ℙ FILENAME
│ next_suffix : NAME ⇸ ℕ
│ programs : PROGRAM ⇸ PATH
├────────────────────────────────────────────────────────────
│ settings(encoding) ∈ {utf8, utf8-bom, cp1252}
│ ∀ n₁, n₂ : used_names • lower(n₁) = lower(n₂) ⇒ n₁ = n₂
│ ∀ b : dom next_suffix; k : ℕ | 2 ≤ k < next_suffix(b) •
│ lower(b ⁀ "_" ⁀ k ⁀ ".csv") ∈ lower⦇used_names⦈
└────────────────────────────────────────────────────────────
The last line is what makes the file-name search fast: every suffix below the remembered starting point is already taken, so starting there finds the same (smallest free) suffix as starting from 2.
csv_name
┌─ CsvName ──────────────────────────────────────────────────
│ ΔExporter
│ table? : NAME ; file! : FILENAME
├────────────────────────────────────────────────────────────
│ b = safe(table?)
│ file! = (if lower(b ⁀ ".csv") ¬in; lower⦇used_names⦈ then b ⁀ ".csv"
│ else b ⁀ "_" ⁀ min{ k : ℕ | k ≥ 2 ∧
│ lower(b ⁀ "_" ⁀ k ⁀ ".csv") ¬in; lower⦇used_names⦈ } ⁀ ".csv")
│ used_names' = used_names ∪ {file!}
└────────────────────────────────────────────────────────────
new
┌─ NewExporter ──────────────────────────────────────────────
│ Exporter'
│ args? : SETTING ⇸ VALUE
├────────────────────────────────────────────────────────────
│ dom args? ⊆ dom NEW_SCHEMA
│ ∀ k : dom args? • valid(NEW_SCHEMA(k), args?(k))
│ settings' = DEFAULTS ⊕ { k : dom args? | args?(k) ≠ undef • k ↦ args?(k) }
│ used_names' = ∅
│ next_suffix' = ∅
│ programs' = ∅
└────────────────────────────────────────────────────────────
run
┌─ Run ──────────────────────────────────────────────────────
│ ΔExporter ; ΔFileSystem
│ database? : PATH ; status! : {0, 1}
│ all, selected : iseq TABLE ; failed : ℙ TABLE
├────────────────────────────────────────────────────────────
│ database? ∈ readableFiles
│ {mdb-tables, mdb-export} ⊆ dom PATH
│ all = sort({ t : tablesOf(database?) | ¬ system(t) })
│ selected = (if tables ¬in; dom settings then all
│ else all ↾ ran settings(tables))
│ settings(dry_run) ⇒ files' = files ∧ status! = 0
│ ¬ settings(dry_run) ⇒
│ failed = { t : ran selected | ¬ exported(t) } ∧
│ (∀ t : ran selected \ failed •
│ files'(output_dir / csvName(t)) = encode(encoding, csv(t))) ∧
│ (∀ t : failed • files'(output_dir / csvName(t)) = files(output_dir / csvName(t))) ∧
│ status! = (if failed = ∅ then 0 else 1)
└────────────────────────────────────────────────────────────
┌─ RunFatal ─────────────────────────────────────────────────
│ ΞFileSystem
│ database? : PATH ; error! : MESSAGE
├────────────────────────────────────────────────────────────
│ database? ¬in; readableFiles ∨ {mdb-tables, mdb-export} ⊈ dom PATH
│ ∨ mdbTablesFails(database?)
│ error! ≠ ∅
└────────────────────────────────────────────────────────────
ExporterRun ≙ Run ∨ RunFatal
STATE DIAGRAM
The life of one exporter object, and of one call to run. Each box is a state. Each arrow shows what causes the change, and what happens on the way.
new(%settings)
action: validate settings, apply defaults
|
v
+----------------+ <-------------------------------------+
| READY | |
+----------------+ |
| run($database) |
v |
+----------------+ database missing or unreadable, |
| CHECKING | mdb-tables/mdb-export not found |
| database and |-------------------------------+ |
| programs | | |
+----------------+ | |
| OK; action: forget old file names; | |
| warn if mdb-count is missing and | |
| switch show_counts off | |
v | |
+----------------+ mdb-tables fails | |
| LISTING |-------------------------------+ |
| tables | | |
+----------------+ | |
| action: drop system tables, sort, | |
| filter by "tables", warn about | |
| unknown names | |
+------------+-------------+ | |
| dry_run | no tables | tables to export | |
v | selected v | |
+------------+ | +----------------+ mkdir fails | |
| DRY RUN | | | PREPARING |------------------+ |
| print list | | | output folder | | |
| to STDOUT | | +----------------+ v |
+------------+ | | folder exists +--------------+|
| | v | FATAL ||
| | +----------------+ | croak; no ||
| | | EXPORTING |<--+ | file written |+
| | | one table | | +--------------+
| | +----------------+ | next table
| | | | |
| | | success | failure (file exists,
| | | action: | mdb-export fails, bad
| | | rename | character, ...)
| | | temp file | action: delete temp file,
| | | into | carp, log, count failure
| | | place, | |
| | | log +------+
| | +------------------+
| | | no tables left
| v v
| +------------------+
| | SUMMARY | action: log "Processed N tables,
| +------------------+ M failed"
| | |
v v v
return 0 return 0 return 1
(to READY) (M = 0) (M > 0)
(to READY) (to READY)
Row counts (show_counts) are counted in DRY RUN and after a table's file is in place. A count that fails is only a warning ("Cannot count the rows of T"): the dry run shows ?, and an exported table still counts as a success.
Not drawn above, because it can happen in every state after READY: an interruption (SIGINT, SIGQUIT, SIGTERM or SIGHUP) goes to FATAL at once. Its action: discard the table being exported (delete its temporary file), start no further table, croak "Interrupted by SIGx".