## Name

Database::Abstraction - Read-only Database Abstraction Layer (ORM)

## Version

Version 0.49

## Description

`Database::Abstraction` is a read-only ORM for Perl that gives a uniform
interface over CSV, PSV, TSV, JSON, XML, SQLite, DBM::Deep, BerkeleyDB, and
Excel (XLS/XLSX) files - local, remote (via SSH), or fetched from a URL -
without writing any SQL.
Effectively it allows you to access a database table, of many different
database formats, as an object.

Key features:

- **No SQL required.**  Use plain Perl method calls for simple lookups and
scans; switch storage formats without changing application code.
- **Rich query criteria.**  Pass plain values, SQL wildcards, `undef` (IS NULL),
comparison operators (`>` `<` `>=` `<=` `!=`), pattern
operators (`-like`, `-not_like`), set operators (`-in`, `-not_in`,
`-between`), and logical groupings (`-or`, `-and`).
- **Automatic joins.**  Add a `join` parameter to any select method to
combine tables with INNER, LEFT, RIGHT, FULL, or CROSS joins.
- **Chained query builder.**  The `query()` method returns a
[Database::Abstraction::Query](https://metacpan.org/pod/Database%3A%3AAbstraction%3A%3AQuery) object for fluent, composable queries:
`$db->query->where(...)->order_by(...)->limit(...)->all()`.
- **Aggregation.**  The `aggregate()` method and the fluent
`->group_by()->measure()->having()->all()` chain push `GROUP BY` to
SQL backends and fold rows in Perl on slurp-only backends.
- **Schema introspection.**  `columns()` lists column names; `schema()`
returns full type/nullability metadata, using native driver introspection
(`PRAGMA table_info` for SQLite, `column_info` for others).
- **DSN portability.**  Pass a `dsn` (plus optional `username`/`password`)
to connect to any DBI-supported database (SQLite, PostgreSQL, MySQL, ...)
instead of pointing at a local file.
- **Performance.**  Small files are slurped into a RAM hash for sub-millisecond
lookups.  All DBI statement handles are cached with `prepare_cached()`.
A CHI-compatible cache layer is also supported.

## Synopsis

```perl
# 1. Create a thin subclass for your table (e.g. Database/Foo.pm)
package Database::Foo;
use parent 'Database::Abstraction';

# 2. Open the database - file is auto-detected from the class name
#    (looks for foo.sql / foo.sqlite / foo.sqlite3 / foo.psv / foo.tsv / foo.csv / foo.xlsx / foo.xml / foo.json / foo.db)
my $db = Database::Foo->new(directory => '/path/to/data');

# 3. Simple lookups -----------------------------------------------

# Fetch one row
my $row = $db->fetchrow_hashref(entry => 'key1');

# Fetch all rows matching a criterion
my $rows = $db->selectall_arrayref(status => 'active');

# Column shortcut via AUTOLOAD
my $name = $db->name(entry => 'key1');

# 4. Rich criteria ------------------------------------------------

# Comparison operators
my $high = $db->selectall_arrayref(score => { '>' => 90 });

# Set membership
my $selected = $db->selectall_arrayref(
    name => { -in => ['Alice', 'Bob'] }
);

# Range
my $mid = $db->selectall_arrayref(
    score => { -between => [60, 80] }
);

# OR grouping
my $either = $db->selectall_arrayref(
    -or => [
        { status => 'active'    },
        { score  => { '>' => 95 } },
    ]
);

# 5. Joins --------------------------------------------------------

my $joined = $db->selectall_arrayref(
    join => { table => 'dept', on => 'foo.dept_id = dept.id', type => 'LEFT' }
);

# 6. Chained query builder ----------------------------------------

my $results = $db->query
    ->where(status => 'active')
    ->where(score  => { '>=' => 80 })
    ->order_by('score DESC')
    ->limit(10)
    ->all();

my $first = $db->query->where(name => 'Alice')->first();
my $count = $db->query->where(status => 'active')->count();

# 7. Connect via DSN (PostgreSQL, MySQL, SQLite, ...) ---------------

my $db2 = Database::Foo->new(
    dsn      => 'dbi:Pg:dbname=mydb;host=db.example.com',
    username => 'myuser',
    password => 's3cret',
);

# 8. Schema introspection -----------------------------------------

my $cols   = $db->columns();  # ['entry', 'name', 'score', ...]
my $schema = $db->schema();   # { name => { type=>'TEXT', nullable=>1, ... }, ... }

# 9. Aggregation (GROUP BY) ----------------------------------------

# Total revenue and order count per region
my $summary = $db->aggregate(
    by       => ['region'],
    measures => [
        { col => 'amount', fn => 'sum',   as => 'revenue' },
        {                  fn => 'count', as => 'orders'  },
    ],
);

# Grand total across all rows
my $totals = $db->aggregate(
    measures => [{ col => 'amount', fn => 'sum', as => 'total' }],
)->[0];

# Fluent style with HAVING and sort
my $top = $db->query
    ->group_by('region')
    ->measure('amount', 'sum',   'revenue')
    ->measure(undef,    'count', 'orders')
    ->having(revenue => { '>' => 1000 })
    ->order_by('revenue DESC')
    ->limit(5)
    ->all();
```

## Quick Start Example

If `/var/dat/foo.csv` contains:

```
"customer_id","name"
"plugh","John"
"xyzzy","Jane"
```

Create a driver in `.../Database/foo.pm`:

```perl
package Database::foo;
use parent 'Database::Abstraction';

# Regular CSV: no entry column, comma-separated
sub new {
    my ($class, %args) = @_;
    return $class->SUPER::new(no_entry => 1, sep_char => ',', %args);
}
```

Then query it:

```perl
my $foo = Database::foo->new(directory => '/var/dat');

# Prints "John"
print 'Customer: ', $foo->name(customer_id => 'plugh'), "\n";

# Returns { customer_id => 'xyzzy', name => 'Jane' }
my $row = $foo->fetchrow_hashref(customer_id => 'xyzzy');
```

## File Formats

The module probes the `directory` for files in this priority order:

- 1. `SQLite`

    File ending `.sql`, `.sqlite`, or `.sqlite3`.
    Requires [DBD::SQLite](https://metacpan.org/pod/DBD%3A%3ASQLite).

- 2. `Deep`

    DBM::Deep file ending `.dbm` or `.deep`.  The entire file is slurped
    into a plain Perl hash on open; all in-memory fast-paths apply.
    Requires [DBM::Deep](https://metacpan.org/pod/DBM%3A%3ADeep) (loaded lazily).

- 3. `PSV`

    Pipe-separated file, ending `.psv`.

- 4. `TSV`

    Tab-separated file, ending `.tsv`.

- 5. `CSV`

    Comma (or custom) separated file, ending `.csv` or `.db`; can be
    gzipped (`.csv.gz` or `.db.gz`).
    **Note:** the default separator is `!` not `,` for historical
    reasons - pass `sep_char => ','` for standard CSVs.
    Requires [Text::xSV::Slurp](https://metacpan.org/pod/Text%3A%3AxSV%3A%3ASlurp) for the slurp fast-path (loaded lazily).

- 6. `Excel` (`.xls`) and `XLSX` (`.xlsx`)

    Two separate Excel backends - one per file format:

    - **.xls** - old binary format, opened via [DBD::Excel](https://metacpan.org/pod/DBD%3A%3AExcel) (which uses
    [Spreadsheet::ParseExcel](https://metacpan.org/pod/Spreadsheet%3A%3AParseExcel) internally).  All queries go through DBI/SQL;
    no in-memory slurp path.  `max_slurp_size` has no effect.
    - **.xlsx** - modern OOXML format, parsed directly via
    [Spreadsheet::ParseXLSX](https://metacpan.org/pod/Spreadsheet%3A%3AParseXLSX) and slurped into an in-memory hash (keyed mode)
    or array (`no_entry` mode).  No DBI handle is created; all queries use
    the in-memory fast-path.  Complex criteria (operator hashes, `-or`/`-and`)
    will fall through to the SQL path and croak - use simple scalar criteria.

    For both formats, each worksheet is a separate logical table; the active
    worksheet is determined by the class-derived table name (or the `table`
    constructor parameter).  Both modules are loaded lazily.

- 7. `XML`

    File ending `.xml`.
    Requires [XML::Simple](https://metacpan.org/pod/XML%3A%3ASimple) for the slurp fast-path (loaded lazily).

- 8. `JSON`

    File ending `.json`, slurped into memory via [JSON::MaybeXS](https://metacpan.org/pod/JSON%3A%3AMaybeXS) (loaded
    lazily).

    The file may contain either a JSON array of row objects:

    ```
    [
      { "entry": "key1", "col": "val1" },
      { "entry": "key2", "col": "val2" }
    ]
    ```

    or a JSON object whose keys are the primary-key values:

    ```
    {
      "key1": { "col": "val1" },
      "key2": { "col": "val2" }
    }
    ```

    In the object form, each key is injected into its row hash under the `id`
    column name (default `entry`), so all normal lookups work identically to
    the array form.

    A zero-byte or whitespace-only file is treated as empty - all query methods
    return 0 / `undef` / `[]` without throwing.
    Requires [JSON::MaybeXS](https://metacpan.org/pod/JSON%3A%3AMaybeXS) (loaded lazily).

- 9. `BerkeleyDB`

    Binary key-value file ending `.db`.

- 10. `HTML`

    HTML page fetched via a `url`.  Pass `url =` 'https://...'> instead of
    `directory`; the module fetches the page with [LWP::UserAgent::Cached](https://metacpan.org/pod/LWP%3A%3AUserAgent%3A%3ACached), parses all
    `<table>` elements with [HTML::TableExtract](https://metacpan.org/pod/HTML%3A%3ATableExtract), and slurps the first (or
    `html_table_index`-selected) table into memory.  The first row of the table
    is treated as column headers.  Both modules are loaded lazily and are not
    required for other backends.

Pass `dsn` to bypass file detection entirely and connect via any DBI driver.
Pass `url` to fetch and slurp data from a remote source without a local
directory.  When the URL returns `Content-Type: application/json` or the URL
path ends in `.json`, the response is parsed as JSON (see item 8 above).
Otherwise the response is parsed as an HTML page (item 10).

Example - fetching CPAN Testers results:

```perl
package Database::cpantesters;
use parent 'Database::Abstraction';

my $db = Database::cpantesters->new(
    url      => 'https://www.cpantesters.org/show/Database-Abstraction.json',
    no_entry => 1,
);
my $passes = $db->selectall_arrayref(grade => 'PASS');
```

## Query Criteria

All select methods (`selectall_arrayref`, `selectall_array`,
`fetchrow_hashref`, `count`) accept the same criteria syntax.

### Plain Value

```perl
status => 'active'          # status = 'active'
name   => undef             # name IS NULL
```

Values containing `%` or `_` are matched with `LIKE`:

```perl
name => 'A%'                # name LIKE 'A%'
```

### Comparison Operator Hashref

```perl
score => { '>'  => 90  }   # score > 90
score => { '<'  => 50  }   # score < 50
score => { '>=' => 80  }   # score >= 80
score => { '<=' => 100 }   # score <= 100
score => { '!=' => 0   }   # score != 0
```

Multiple operators on one column are ANDed:

```perl
score => { '>' => 60, '<' => 90 }   # 60 < score < 90
```

### Pattern Matching

```perl
name => { -like     => 'A%'  }   # name LIKE 'A%'
name => { -not_like => 'Z%'  }   # name NOT LIKE 'Z%'
```

### Set Membership

```perl
name => { -in     => ['Alice', 'Bob'] }   # name IN (...)
name => { -not_in => ['Alice', 'Bob'] }   # name NOT IN (...)
```

### Range

```perl
score => { -between => [60, 90] }   # score BETWEEN 60 AND 90
```

### Logical Groupings

`-or` and `-and` take an arrayref of condition hashrefs:

```perl
-or => [
    { status => 'active'        },
    { score  => { '>' => 95 }   },
]

-and => [
    { status => 'active'        },
    { score  => { '>=' => 80 }  },
]
```

### Joins

Any select method accepts a `join` key with a hashref (or arrayref of
hashrefs) describing the join:

```perl
join => {
    table => 'dept',
    on    => 'employees.dept_id = dept.id',
    type  => 'LEFT',    # INNER (default) | LEFT | RIGHT | FULL | CROSS
}

# Multiple joins
join => [
    { table => 'dept',    on => 'e.dept_id   = dept.id'   },
    { table => 'country', on => 'e.country_id = country.id' },
]
```

## Subroutines/Methods

### Init

Set class-level defaults shared by all instances.

```perl
Database::Abstraction::init(directory => '../data');
```

Accepts the same parameters as ["new"](#new).  Returns a reference to the
current defaults hash, so you can read them back:

```perl
my $defaults = Database::Abstraction::init();
print $defaults->{'directory'}, "\n";
```

### Import

The module can be initialised by the `use` directive.

```perl
use Database::Abstraction 'directory' => '/etc/data';
```

or

```perl
use Database::Abstraction { 'directory' => '/etc/data' };
```

### New

Create an object pointing to a read-only database.

Accepts arguments as a hash, a hashref, or - as a shortcut - a single bare
string which is taken to be `directory`.

#### Connection Parameters

- `directory`

    Directory containing the data files.  The module probes this directory for
    files named after the subclass (see ["FILE FORMATS"](#file-formats)).  Required unless
    `dsn` is given.

- `dsn`

    A DBI data-source string (e.g. `dbi:SQLite:dbname=/path/to/db` or
    `dbi:Pg:dbname=mydb;host=db.example.com`).  When present, file detection
    is skipped entirely and the DSN is used directly.  The SQL dialect is
    inferred from the DSN prefix (`sqlite`, `postgres`, `mysql`).

- `username`

    Database username.  Used only with `dsn`; ignored for file-based backends.

- `password`

    Database password.  Used only with `dsn`; ignored for file-based backends.

- `dbname`

    Override the filename stem searched in `directory` (default: the table
    name derived from the class name).

- `table`

    Override the table (or worksheet) name used in SQL queries for this object.
    Default is the class-name-derived table name (e.g. `Database::Foo` =>
    `foo`).  Particularly useful for Excel workbooks where a single `.xlsx`
    file contains multiple worksheets: pass `table => 'Summary'` to query
    the `Summary` worksheet without creating a dedicated subclass.  Also works
    with SQLite/DSN connections to select a table other than the class-derived
    default.  The filename stem (`dbname`) continues to fall back to the class
    name, so the correct file is opened regardless of this override.  The value
    is validated against `$SAFE_QUALIFIED` at construction time.

- `filename`

    Override the full filename (relative to `directory`).  Takes precedence
    over `dbname`.

- `host`

    Remote hostname (or `user@host`) from which to fetch the data file(s) via
    SSH/SCP.  When present, each candidate filename is fetched with
    [File::Slurp::Remote](https://metacpan.org/pod/File%3A%3ASlurp%3A%3ARemote) into a local temporary directory; the existing
    extension-based file-type detection then runs against that directory.
    `directory` is treated as the remote path (no local canonicalization is
    applied).  Using `filename` together with `host` avoids probing multiple
    extensions and is therefore more efficient.  [File::Slurp::Remote](https://metacpan.org/pod/File%3A%3ASlurp%3A%3ARemote) must be
    installed; it is loaded lazily (only when `host` is given).

- `url`

    A URL (`http://` or `https://`) pointing to an HTML page that contains one
    or more `<table>` elements.  When present, `directory` is not required.
    The first row of the selected table is used as column headers.
    Requires [LWP::UserAgent::Cached](https://metacpan.org/pod/LWP%3A%3AUserAgent%3A%3ACached) and [HTML::TableExtract](https://metacpan.org/pod/HTML%3A%3ATableExtract) (both loaded lazily).

- `database`

    A Redis connection URL in the form `redis://[password@]host[:port][/db_index]`.
    When present, `directory`, `dsn`, and `url` are not required.  Rows are
    expected to be stored as Redis Hashes at keys of the form
    `tablename:entry_value`; fields of each Hash become column values.  The
    module slurps all matching keys via `KEYS tablename:*` + `HGETALL` into the
    same in-memory structure used by the CSV and DBM::Deep backends, so all
    existing fast-paths (`selectall_arrayref`, `selectall_array`,
    `fetchrow_hashref`, `count`, `columns`, `schema`, AUTOLOAD) work without
    modification.  `no_entry` mode is supported.

    [Redis::Fast](https://metacpan.org/pod/Redis%3A%3AFast) is tried first (XS, faster); if unavailable, [Redis](https://metacpan.org/pod/Redis)
    (pure-Perl) is used as a fallback.  Both are loaded lazily - neither is
    required when using file-based or DSN-based backends.  The URL scheme is
    validated at construction time (`redis://` only; all other schemes croak).

    Use ["select"](#select) to switch the active Redis database on an already-open
    connection.

#### Behaviour Parameters

- `no_entry`

    Set to `1` when the table has no key column (standard CSVs, for example).
    Default is `0` (keyed on `entry`).

- `id`

    Name of the key column.  Default is `entry`.

- `sep_char`

    Field separator for CSV/PSV files.
    Default is `!` - pass `sep_char => ','`
    for standard comma-separated files.

- `max_slurp_size`

    Files smaller than this (in bytes) are loaded entirely into memory for fast
    lookups.  Default is 16 KB.  Set to `0` to force SQL mode for all sizes.

- `no_fixate`

    Set to `1` to return mutable arrays.  Default is `0` (arrays are made
    read-only via [Data::Reuse](https://metacpan.org/pod/Data%3A%3AReuse)).

- `auto_load`

    Set to `0` to disable the AUTOLOAD column shortcut.  Default is `1`
    (enabled).

- `html_table_index`

    Zero-based index of the HTML `<table>` to extract when the `url`
    backend is used.  Default is `0` (the first table on the page).

- `infer_types`

    Set to `1` to enable heuristic type inference in `schema()` for
    slurp-backed sources (CSV, JSON, XLSX, XML, HTML URL, DBM::Deep).
    Default is `0` (off; all columns reported as `TEXT` for backward
    compatibility).

    When enabled, `schema()` scans the first 100 rows and promotes column
    types: all-integer values => `INTEGER`; all-floating-point values =>
    `REAL`; all ISO-8601 timestamps => `TIMESTAMP`; all ISO-8601 dates =>
    `DATE`; otherwise `TEXT`.  `NULL` (`undef` or empty string) values
    are skipped during the scan.

- `base_criteria`

    A hashref of column-value pairs that is ANDed into **every** SELECT
    automatically.  Callers do not need to repeat these criteria on each
    call.  Common use cases:

    - Row-level security: `base_criteria => { tenant_id => $tid }`
    - Soft-delete filtering: `base_criteria => { deleted_at => undef }`
    - Status gates: `base_criteria => { active => 1 }`

    The hashref is shallow-copied at construction time so later mutations to
    the original do not affect the object.  Column names are validated
    against the same identifier rules as the `id` parameter.  `-or` and
    `-and` groupings are allowed as keys.

    **SQL path**: the criteria are merged into the `WHERE` clause before
    the caller-supplied criteria (caller values win on key collision).

    **Slurp/in-memory path**: the criteria are applied as an additional
    filter.  The `fetchrow_hashref` entry-key fast path checks
    `base_criteria` against the returned row before returning it.

    **Query builder**: `base_criteria` are merged into the `WHERE` clause
    generated by `_build_sql`, so `$db->query->where(...)->all()`
    also respects them.

#### Caching and Logging

- `cache`

    A [CHI](https://metacpan.org/pod/CHI)-compatible cache object.  When set, query results are stored and
    retrieved from the cache.

- `cache_duration` / `expires_in`

    TTL for cached results.  Default is `'1 hour'`.  `expires_in` is a
    synonym for compatibility with [CHI](https://metacpan.org/pod/CHI).

- `logger`

    An object that understands `warn()` and `trace()` (e.g.
    [Log::Log4perl](https://metacpan.org/pod/Log%3A%3ALog4perl), [Log::Any](https://metacpan.org/pod/Log%3A%3AAny)), a code reference, or a filename.

- `config_file`

    Path to a YAML, XML, or INI configuration file whose keys are merged into
    the constructor arguments.  Loaded via [Object::Configure](https://metacpan.org/pod/Object%3A%3AConfigure).

#### Notes

- If no arguments are set, class-level defaults set via `init()` or `use`
are used.
- Slurp mode assumes the key column (`entry`) is unique.  If it is not,
searches will be incomplete - disable slurp mode by setting
`max_slurp_size => 0`.
- Passing an existing object as `$class` clones it, merging any new
arguments.

### Set\_Logger

Sets the class, code reference, or file that will be used for logging.

### Select

Select a Redis database by number (0-15).  Clears the in-memory data cache so
the next query re-slurps from the newly selected database.  Returns `$self`
for chaining.  Croaks if the object is not backed by a Redis connection.

```perl
$db->select(3);          # switch to Redis DB 3
my $count = $db->count;  # queries the new DB
```

### Selectall\_Arrayref

Returns a reference to an array of hash references for every row that
matches the given criteria, or `undef` when there are no matches.

```perl
my $rows = $db->selectall_arrayref();                    # all rows
my $rows = $db->selectall_arrayref(status => 'active');  # exact match
my $rows = $db->selectall_arrayref(score => { '>' => 8 });  # operator
```

The full criteria syntax is described in ["QUERY CRITERIA"](#query-criteria).

Pass a `join` key to combine with another table:

```perl
my $rows = $db->selectall_arrayref(
    dept_name => 'Engineering',
    join      => { table => 'dept', on => 'e.dept_id = dept.id' },
);
```

Pass `limit => N` and/or `offset => M` for pagination:

```perl
my $page = $db->selectall_arrayref(status => 'active', limit => 10, offset => 20);
```

Both values must be non-negative integers; invalid values are ignored with a
`carp` warning.  When `offset` is given without `limit` the SQL backend
uses `LIMIT -1` on SQLite (meaning "no upper bound") so the `OFFSET` clause
is legal.

Pass `sort_by => 'col'` (ascending) or `sort_by => ['col', 'DESC']`
to request a specific sort column and direction instead of the default primary-key
ordering:

```perl
my $rows = $db->selectall_arrayref(sort_by => 'name');
my $rows = $db->selectall_arrayref(sort_by => ['score', 'DESC']);
```

The column name is validated against the same identifier rules as all other
column parameters.  An unsafe name or an unrecognised direction (anything other
than `ASC` or `DESC`, case-insensitive) is ignored with a `carp` warning and
the default sort order is used instead.  Sorting is applied before
`limit`/`offset` pagination.

Results are returned in the cache (if configured) and the returned array
reference is made read-only unless `no_fixate` was set.

**Note:** this always returns all matching rows.  Use ["selectall\_array"](#selectall_array)
in scalar context, or `$db->query->limit(1)->all()`, to fetch just one row.

#### Pseudocode

```
1. Parse criteria; extract and build any JOIN clause.
2. If data is slurped AND no joins AND criteria are simple:
   a. No criteria -> return all rows as arrayref.
   b. entry-only lookup -> return [$data{entry}].
   c. Otherwise -> scan rows in-memory with _match_criterion.
   In all slurp cases: sort by sort_by column (if given), then apply offset/limit.
3. Otherwise build SQL: SELECT * FROM table [JOIN] [WHERE]
   ORDER BY sort_by [else id] [LIMIT] [OFFSET].
4. Check cache; return cached arrayref on HIT.
5. prepare_cached + execute; fetch all rows.
6. Store result in cache; fixate the array; return arrayref.
```

#### Api Specification

##### Arguments

```perl
%criteria   # optional key-value pairs or operator hashrefs
join        => HashRef | ArrayRef  # optional join spec(s)
sort_by     => Str | ArrayRef      # optional: 'col' or ['col','DESC']
limit       => Int                 # optional: max rows
offset      => Int                 # optional: rows to skip
no_fixate   => Bool                # optional: skip read-only locking
table       => Str                 # optional: override table name
```

##### Returns

```
ArrayRef[HashRef] | undef
```

An arrayref of row hashrefs on success.  Returns `undef` when no
rows match.  Each hashref is read-only unless `no_fixate` was set.

### Selectall\_Hashref

Deprecated alias for ["selectall\_arrayref"](#selectall_arrayref).  Use `selectall_arrayref` in
new code.

### Each\_Row

```perl
$db->each_row(\&callback);
$db->each_row(\&callback, status => 'active');
$db->each_row(\&callback, sort_by => 'name', limit => 100, offset => 20);
```

Iterates over matching rows one at a time, calling `\&callback` once per
row with the row hashref as the sole argument.  Uses **constant memory** on
the SQL path: rows are fetched from the database one at a time via
`fetchrow_hashref` without materialising the full result array.  On the
slurp/in-memory path data is already in RAM, so memory use is equivalent to
["selectall\_arrayref"](#selectall_arrayref).

Accepts the same criteria, `join`, `sort_by`, `limit`, and `offset`
parameters as ["selectall\_arrayref"](#selectall_arrayref).

Returns the number of rows passed to `\&callback`.

Exceptions raised inside `\&callback` abort iteration and propagate to
the caller; the DBI statement handle is left in a valid state (`finish()`
is called on exception).

**Note:** rows on the SQL path are not fixated (made read-only) because they
are discarded after each callback invocation.  Slurp-path rows are already
fixated from the initial load.

#### Api Specification

##### Arguments

```perl
CodeRef     # required: callback receiving one row hashref per call
%criteria   # optional: same criteria syntax as selectall_arrayref
join        => HashRef | ArrayRef  # optional join spec(s)
sort_by     => Str | ArrayRef      # optional sort column
limit       => Int                 # optional: max rows to visit
offset      => Int                 # optional: rows to skip
```

##### Returns

```
Int (>= 0)
```

The number of rows visited (i.e. the number of times the callback
was called).  Any exception thrown inside the callback is re-thrown
after the DBI statement handle is properly finished.

### Selectall\_Array

Similar to ["selectall\_arrayref"](#selectall_arrayref) but returns a list of hash references
rather than a reference to an array.

```perl
my @rows = $db->selectall_array(status => 'active');
```

In **scalar context** it applies `LIMIT 1` and returns just the first
matching hash reference - making it more efficient than `selectall_arrayref`
when you only need one row.  In **list context** all matching rows are returned.

Accepts the same criteria, `join`, `limit`, `offset`, and `sort_by`
parameters as ["selectall\_arrayref"](#selectall_arrayref).  When `limit` is given in scalar context
it overrides the implicit `LIMIT 1`.

### Selectall\_Hash

Deprecated alias for ["selectall\_array"](#selectall_array).  Use `selectall_array` in new
code.

### Count

Returns the number of rows matching the given criteria.

```perl
my $total  = $db->count();
my $active = $db->count(status => 'active');
my $high   = $db->count(score  => { '>' => 90 });
```

Accepts the full criteria syntax described in ["QUERY CRITERIA"](#query-criteria).

#### Api Specification

##### Arguments

```perl
%criteria   # optional: same criteria syntax as selectall_arrayref
join        => HashRef | ArrayRef  # optional join spec(s)
table       => Str                 # optional: override table name
```

##### Returns

```
Int (>= 0)
```

The number of rows that match the given criteria.  Returns `0`
when no rows match -- never `undef`.

### Fetchrow\_Hashref

Returns a hash reference for the first row matching the given criteria,
or `undef` when there is no match.  Always applies `LIMIT 1`.

```perl
my $row = $db->fetchrow_hashref(entry => 'key1');
my $row = $db->fetchrow_hashref(score => { '>=' => 10 });
```

When `no_entry` is **not** set you may pass a single bare value and it is
used as the `entry` key:

```perl
my $row = $db->fetchrow_hashref('key1');    # same as entry => 'key1'
```

Accepts the full criteria syntax described in ["QUERY CRITERIA"](#query-criteria), including
the `join` parameter:

```perl
my $row = $db->fetchrow_hashref(
    name => 'Alice',
    join => { table => 'dept', on => 'e.dept_id = dept.id' },
);
```

Pass `table => $other_table` to query a table other than the one
derived from the class name.

#### Api Specification

##### Arguments

```perl
entry       => Str    # primary-key value to look up (positional first
                      # arg when no_entry is false)
%criteria   # optional additional column filters
join        => HashRef | ArrayRef  # optional join spec(s)
no_fixate   => Bool                # optional: skip read-only locking
table       => Str                 # optional: override table name
```

##### Returns

```
HashRef | undef
```

A single row hashref when exactly one matching row is found.  Returns
`undef` when the key is not found or no row matches the criteria.
The hashref is read-only unless `no_fixate` was set.

**Note:** `fetchrow_hashref` does **not** croak when multiple rows
match the given criteria; it returns whichever row the backend
delivers first (usually the first row in file/insertion order).

### Execute

Execute a raw SQL query on the underlying database.

```perl
# Scalar context: returns the first row as a hashref
my $row = $db->execute(query => 'SELECT * FROM foo WHERE id = 1');

# List context: returns all rows as a list of hashrefs
my @rows = $db->execute(query => 'SELECT * FROM foo WHERE score > ?',
                        args  => [80]);
```

The `FROM <table>` clause is appended automatically if omitted.

On CSV tables without `no_entry` it may help to add
`WHERE entry IS NOT NULL AND entry NOT LIKE '#%'` to filter comment rows.

If the data have been slurped into memory this method still hits the actual
database file directly.

`args` is an arrayref of bind values (see ["execute" in DBI](https://metacpan.org/pod/DBI#execute)).

### Updated

Returns the Unix timestamp of the last database update.

For file-based backends (CSV, XML, SQLite via `directory`), this is the
mtime of the backing file, set at `new()` time.

For SQLite DSN connections (`dbi:SQLite:dbname=...`), the file path is
extracted from the DSN and `stat()`-ed live on every call, so callers
get a current mtime suitable for cache-invalidation even when the database
was opened via a DSN rather than a `directory`.

For all other DSN-based connections (PostgreSQL, MySQL, etc.) and for
URL-based backends, returns the Unix timestamp of the most recent
`new()` call (connection time).

### Columns

Returns an array reference of column names for the current table.

```perl
my $cols = $db->columns();    # e.g. ['entry', 'name', 'score', 'status']
```

Column names are always returned in alphabetical (lexicographic) order,
regardless of backend. This makes the result stable and portable when the
same logical table is backed by different engines (CSV => SQLite, etc.).

The source of column names varies by backend:

- **Slurp mode** - keys of the first row in memory.
- **SQLite / other DBI** - a zero-row `SELECT *` exposes the driver's
`NAME` attribute.
- **BerkeleyDB** - always returns `['entry', 'value']`.

The result is cached inside the object after the first call.

#### Api Specification

##### Arguments

None beyond the implicit invocant.  The result is cached in the
object after the first call.

##### Returns

```
ArrayRef[Str]
```

A sorted (lexicographic, ascending) arrayref of column names.  The
sort order is identical across all backends, so switching from CSV
to SQLite does not change the returned order.

### Schema

Returns a hash reference describing the schema of the current table.
Each key is a column name; each value is a hash reference with these keys:

- `type` - data type string (e.g. `TEXT`, `INTEGER`, `REAL`)
- `nullable` - `1` if the column may be NULL, `0` if NOT NULL
- `default` - default value string, or `undef`
- `pk` - `1` if this column is (part of) the primary key, `0` otherwise

```perl
my $schema = $db->schema();

for my $col (sort keys %{$schema}) {
    my $info = $schema->{$col};
    printf "%s  %s  %s\n",
        $col,
        $info->{type},
        $info->{nullable} ? 'NULL' : 'NOT NULL';
}
```

The schema is determined by the backend:

- **SQLite** - `PRAGMA table_info(table)`
- **Other DBI drivers** - `$dbh->column_info(...)`
- **Slurp mode** - inferred from the first row; all columns typed as
`TEXT` by default.  When `infer_types => 1` was passed to the
constructor, up to 100 rows are scanned per column and the type is
promoted: all-integer values => `INTEGER`; all-float values =>
`REAL`; all ISO-8601 timestamps (`YYYY-MM-DDThh:mm`) => `TIMESTAMP`;
all ISO-8601 dates (`YYYY-MM-DD`) => `DATE`; otherwise `TEXT`.
`undef` and empty-string values are treated as SQL `NULL` and skipped
during the scan.
- **BerkeleyDB** - always returns `entry` (pk) and `value`

The result is cached inside the object after the first call.

#### Api Specification

##### Arguments

```perl
infer_types => Bool  # enable heuristic type inference for slurp backends
                     # (may also be set at construction time)
```

##### Returns

```perl
HashRef[Str => HashRef]
```

A hashref keyed by column name.  Each value is a hashref with:

```perl
type     => Str          # 'TEXT', 'INTEGER', 'REAL', 'TIMESTAMP', 'DATE'
nullable => Bool         # 1 if the column may be NULL, 0 if NOT NULL
default  => Str | undef  # default value, or undef
pk       => Bool         # 1 if (part of) the primary key
```

For slurp-mode backends all columns default to type `TEXT` unless
`infer_types` is set.  The result is cached in the object after the
first call.

### Dbi\_Source

Returns a hashref `{ dbh => $dbh, table => $name }` when the backend
is a live SQLite connection, or `undef` for every other backend (slurp-mode
CSV, JSON, XLSX, HTML URL, DBM::Deep, BerkeleyDB, PostgreSQL, MySQL, ...).

The hashref is consumed by `Database::Join` to perform a zero-copy
`ATTACH DATABASE` so rows never pass through Perl.
Nested `Database::Join` objects that are themselves SQLite-backed expose themselves
as attachable sources to parent joins through the same interface.

Subclasses may override this method to expose non-SQLite DBI connections
if their join layer supports them.

#### Api Specification

##### Arguments

None beyond the implicit invocant.

##### Returns

A hashref `{ dbh => DBI::db, table => Str }` on a SQLite-backed
instance, or `undef` on all other backends.

### Aggregate

Groups matching rows and computes aggregate measures over each group.
Pushes `GROUP BY` SQL to the backend when a DBI handle is available;
otherwise folds rows in Perl (BerkeleyDB, DBM::Deep, XLSX, JSON, HTML, Redis).

```perl
my $rows = $db->aggregate(
    by       => ['region', 'month'],
    measures => [
        { col => 'amount', fn => 'sum',   as => 'revenue' },
        {                  fn => 'count', as => 'orders'  },
    ],
    status => 'active',
);

# Grand total (no 'by')
my $row = $db->aggregate(
    measures => [{ col => 'score', fn => 'avg', as => 'mean_score' }],
)->[0];
```

Reserved parameter keys (extracted before the rest is treated as WHERE criteria):
`by`, `measures`, `having`, `sort_by`, `limit`, `offset`, `table`, `join`.

Always returns an **arrayref of hashrefs** (never `undef`); returns `[]` when
no group qualifies.  Row keys are the `by` column names (in declaration order)
followed by the measure aliases.

#### Measure Specification

Each measure hashref:

```perl
{ col => 'amount', fn => 'sum',           as => 'revenue'   }
{                  fn => 'count',         as => 'orders'    }  # COUNT(*)
{ col => 'amount', fn => 'count',         as => 'paid'      }  # COUNT(col)
{ col => 'customer', fn => 'count_distinct', as => 'customers' }
```

`fn` must be one of `sum`, `avg`, `min`, `max`, `count`, `count_distinct`
(case-insensitive).  `col` is required for every function except plain
`count`.  `as` (the result key) must be a valid SQL identifier and must not
duplicate another alias or a `by` column name.

#### Having

Pass `having => { alias_or_by_col => criterion }` to filter groups
after aggregation.  Accepts the full criteria syntax.

```perl
having => { revenue => { '>' => 1000 } }
```

#### Pseudocode

```
1. _open_table; croak if JOIN requested on no-DBI backend.
2. _validate_aggregate_spec: extract/validate by/measures/having/
   sort_by/limit/offset; remainder of params is WHERE criteria.
3. Merge base_criteria.
4. SQL path (DBI handle): _build_aggregate_sql; prepare_cached+execute;
   normalise empty-set values; cache; fixate; return.
5. Perl fold: selectall_arrayref(criteria); _fold_aggregate;
   HAVING via _match_criteria; sort; offset; limit; fixate; return.
```

#### Backend Notes

On slurp-only backends all matching rows are materialised before folding.
Use a SQL backend (SQLite DSN, PostgreSQL, MySQL) for large datasets.

#### Api Specification

##### Arguments

```perl
by         => ArrayRef[Str]     # optional: GROUP BY column names
measures   => ArrayRef[HashRef] # required: one or more measure specs
having     => HashRef           # optional: post-group filter criteria
sort_by    => Str | ArrayRef    # optional: sort column (Str) or [col,dir]
limit      => Int               # optional: max rows to return
offset     => Int               # optional: rows to skip before returning
join       => HashRef | ArrayRef # optional: join spec(s) -- SQL path only
no_fixate  => Bool              # optional: skip read-only locking
%criteria                       # optional: WHERE filter before grouping
```

Each element of `measures` is a hashref with keys:

```perl
col  => Str    # column to aggregate; omit only for plain count()
fn   => Str    # one of: sum avg min max count count_distinct
as   => Str    # result key name (required; valid SQL identifier)
```

##### Returns

```
ArrayRef[HashRef]
```

An arrayref of hashrefs.  Each hashref has one key per `by` column
and one key per measure `as` alias.  Returns `[]` (empty arrayref)
when no groups match -- never `undef`.

Empty-set normalisation: `sum`/`count`/`count_distinct` become
`0`; `avg`/`min`/`max` become `undef` when no rows are present.

### Query

Returns a new [Database::Abstraction::Query](https://metacpan.org/pod/Database%3A%3AAbstraction%3A%3AQuery) builder object bound to this
database instance, for fluent method-chaining queries.

```perl
# All active rows with high scores, newest first, max 10
my $rows = $db->query
    ->where(status => 'active')
    ->where(score  => { '>' => 80 })
    ->order_by('score DESC')
    ->limit(10)
    ->all();

# Single row
my $row = $db->query->where(name => 'Alice')->first();

# Just a count
my $n = $db->query->where(status => 'active')->count();
```

See [Database::Abstraction::Query](https://metacpan.org/pod/Database%3A%3AAbstraction%3A%3AQuery) for the full API.

### AUTOLOAD - Column Shortcut

Calling an unknown method whose name matches a column name performs a column
lookup.  The method name is the column you want; the arguments are criteria.

```perl
# Scalar context: return the first match
my $name = $db->name(entry => 'key1');

# List context: return all matching values
my @names = $db->name();

# Shortcut when the table has an 'entry' key column
my $name = $db->name('key1');    # same as name(entry => 'key1')

# Unique/distinct values
my @statuses = $db->status(distinct => 1);
```

**In list context** the full column is returned (all rows), ordered by the
column value.  **In scalar context** only the first match is returned
(`LIMIT 1`).

Results come from the slurp cache when available.

Throws an error if the column does not exist (slurp mode) or if AUTOLOAD
has been disabled with `auto_load => 0`.

#### Pseudocode

```perl
1. Extract column name from $AUTOLOAD; guard on DESTROY.
2. Croak if auto_load => 0.
3. Validate $column against /^[a-zA-Z_][a-zA-Z0-9_]*$/.
4. If data is slurped:
   a. List context, no params -> map column over all rows (exists guard).
   b. entry-only param -> direct hash lookup (exists guard).
   c. No params, scalar -> first value in hash.
   d. no_entry set -> scan array for matching key/value pair.
   e. Other params -> scan keyed hash for matching column.
5. If not slurped, build SQL:
   - List:   SELECT column FROM table [WHERE ...] ORDER BY column
   - Scalar: SELECT DISTINCT column FROM table [WHERE ...] LIMIT 1
6. Check cache; return on HIT.
7. prepare_cached + execute; fetch result.
8. Store in cache; fixate; return.
```

## Author

Nigel Horne, `<njh at nigelhorne.com>`

## Support

This module is provided as-is without any warranty.

Please report any bugs or feature requests to `bug-database-abstraction at rt.cpan.org`,
or through the web interface at
[http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Database-Abstraction](http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Database-Abstraction).
I will be notified, and then you'll
automatically be notified of progress on your bug as I make changes.

## Messages

The table below lists every error that the module can croak or carp, what
triggers it, and how to resolve it.

- `_Class_: abstract class`

    Direct instantiation of `Database::Abstraction` was attempted.
    Create a subclass and instantiate that instead.

- `_Class_: where are the files?`

    Neither `directory` nor `dsn` was supplied to `new()`.

- `_Class_: _/path_ is not a directory`

    The `directory` argument exists on disk but is not a directory.

- `_Class_: cannot connect: _$DBI::errstr_`

    DBI failed to connect to the given `dsn`.  Check credentials and host.

- `Can't find a file called '_name_' for the table _T_ in _dir_`

    None of the probe extensions (`.sql`, `.sqlite`, `.sqlite3`, `.psv`, `.tsv`, `.csv`, `.xlsx`, `.db`, `.xml`)
    matched in `directory`.

- `_Class_: prepare failed: _$errstr_`

    `prepare_cached()` returned false.  Usually a syntax error in an internally
    built query; file a bug if you see this from a normal API call.

- `_build_where_conditions: unsafe column name '_name_'`

    A criteria key contained characters outside `[A-Za-z0-9_.]`.
    This is a SQL-injection guard.  Use only valid SQL identifier characters.

- `join: missing "table"` / `join: missing "on" condition`

    A join spec hashref is incomplete.  Both `table` and `on` are required.

- `Invalid JOIN type: _TYPE_`

    `type` in a join spec was not one of `INNER LEFT RIGHT FULL CROSS`.

- `_Class_: Unknown column _col_` / `_Class_: AUTOLOAD disabled`

    An AUTOLOAD call was made for a column that does not exist, or AUTOLOAD
    was disabled with `auto_load => 0`.

- `Usage: set_logger(logger => $logger)`

    `set_logger()` was called without a `logger` argument.

- `Usage: execute(query => $query)`

    `execute()` was called without a `query` argument.

- `XML slurp: _..._ is not yet supported`

    The XML file structure is too complex for slurp mode.
    Use `max_slurp_size => 0` to force the DBI/XMLSimple SQL path.

- `_Class_: _method_ is meaningless on a NoSQL database`

    A relational method (`selectall_arrayref`, `count`, `execute`, etc.)
    was called on a BerkeleyDB backend, which only supports key-value lookup
    via `fetchrow_hashref`.

- `_Class_: aggregate: 'measures' must be a non-empty arrayref`

    `aggregate()` was called without a `measures` arrayref, or with an empty one.

- `_Class_: aggregate: measure must be a hashref, got _ref_`

    An element of the `measures` arrayref is not a hashref.

- `_Class_: aggregate: unknown measure function '_fn_'`

    `fn` must be one of `sum`, `avg`, `min`, `max`, `count`, `count_distinct`.

- `_Class_: aggregate: measure column is required for function '_fn_'`

    `col` was omitted for a function that requires it.  Only plain `count` (COUNT(\*)) accepts a missing `col`.

- `_Class_: aggregate: unsafe measure column '_col_'`

    The `col` value is not a valid SQL identifier.

- `_Class_: aggregate: measure 'as' is required`

    The `as` alias was missing from a measure hashref.

- `_Class_: aggregate: unsafe measure alias '_as_'`

    The `as` value is not a valid SQL identifier.

- `_Class_: aggregate: duplicate measure alias '_as_'`

    Two measure hashrefs share the same `as` alias.

- `_Class_: aggregate: measure alias '_as_' collides with a group-by column`

    A measure alias duplicates a name in the `by` list.

- `_Class_: aggregate: 'by' must be an arrayref of column names`

    The `by` parameter was not an arrayref.

- `_Class_: aggregate: unsafe group-by column '_col_'`

    A `by` element is not a valid SQL identifier.

- `_Class_: aggregate: having key '_key_' is not a group-by column or measure alias`

    A `having` key does not match any `by` column name or measure `as` alias.

- `_Class_: aggregate: sort_by names '_name_', which is not a group-by column or measure alias`

    The `sort_by` column is not one of the `by` columns or measure aliases.

- `_Class_: aggregate: cannot aggregate a JOIN on the _backend_ backend`

    `join` was passed to `aggregate()` on a slurp-only backend that has no DBI handle.

- `Database::Abstraction::Query: count() is not available once measure() has been called`

    `count()` was called on a query builder that has measures set.  Use `all()` and check `scalar @{$rows}`.

## Common Pitfalls

The following mistakes cause unexpected behaviour in common usage.
Read this section before filing a bug report.

- **Default CSV separator is `!`, not `,`**

    For historical reasons the module defaults to bang (`!`) as the
    field separator for CSV files.  Standard RFC 4180 comma-separated
    files require you to pass `sep_char => ','` in your subclass
    constructor.

    ```perl
    sub new {
        my ($class, %args) = @_;
        return $class->SUPER::new(sep_char => ',', %args);
    }
    ```

- **Primary key column is named `entry`, not `key`**

    The default primary-key column name is `entry` because `key` is
    a reserved word in SQL.  If your data uses a different column name
    (e.g. `id`, `customer_id`), pass `id => 'customer_id'` to
    `new()`.

- **Duplicate primary keys silently overwrite earlier rows**

    In slurp mode the entire data file is loaded into a Perl hash keyed
    by the `entry` column.  If two rows share the same key value the
    second row replaces the first.  To keep duplicates either disable
    slurp with `max_slurp_size => 0` or switch to a `no_entry`
    schema.

- **Operator-hash criteria do not work on no-DBI backends**

    No-DBI backends (XLSX, DBM::Deep, JSON, HTML URL, Redis) have no SQL
    engine.  Criteria that use operator hashes (e.g.
    `score => { '>=' => 80 }`) trigger the SQL path and will croak
    with a "meaningless on a NoSQL database" error.  Use simple scalar
    criteria (`score => 80`) on these backends.

- **Read-only results: do not modify returned hashrefs**

    Unless you pass `no_fixate => 1`, every hashref returned by
    `selectall_arrayref`, `fetchrow_hashref`, and similar methods is
    made read-only by `Data::Reuse`.  Attempting to assign a value to
    a key in the returned hashref will throw an exception.  If you need
    to modify a row, either copy it first (`my %row = %{$orig_row}`) or
    construct the object with `no_fixate => 1`.

- **`where()` calls in the query builder use AND semantics**

    Each call to `$q->where(...)` adds an additional required
    condition.  Two separate `where()` calls do NOT produce OR
    semantics.  To express OR, pass `-or => [...]` inside a single
    `where()` call:

    ```perl
    # Wrong: this is AND, not OR
    $q->where(status => 'active')->where(status => 'pending');

    # Correct: OR grouping inside one where() call
    $q->where(-or => [{ status => 'active' }, { status => 'pending' }]);
    ```

- **`aggregate()` `sort_by` must name a grouped or aliased column**

    The `sort_by` parameter for `aggregate()` only accepts column names
    from the `by` list or measure `as` aliases.  You cannot sort
    aggregate results by a raw data column that is not part of the group.
    This restriction applies equally to the `->order_by()` call on a
    query builder that has `measure()` methods set.

- **`sort_by` is not an argument to the query builder's `all()`**

    When using the fluent query builder to build an aggregate query, set
    the sort column via `->order_by('col')` before calling
    `->all()`.  Passing `->all(sort_by => 'col')` has no
    effect because the `all()` terminal method ignores extra arguments.

- **`Params::Get` positional mapping with `no_entry = 0`**

    When `no_entry` is false (the default), the first positional argument
    to `selectall_arrayref` and `fetchrow_hashref` is silently mapped to
    the `entry` key.  Passing an operator-hash criteria in that position
    (e.g. `selectall_arrayref(status =` { '!=' => 'x' })>) causes
    `status` to be treated as the entry value rather than a column name,
    which will produce wrong results.  Always pass a hashref of criteria
    as a single reference, or use `no_entry =` 1> in your subclass.

## Encoding

- **Column names** must match the pattern `[a-zA-Z_][a-zA-Z0-9_.]*`.
Only ASCII letters, digits, underscores, and dots (for qualified names
such as `table.column`) are accepted.  Non-ASCII Unicode characters
in column names will cause a croak with an "unsafe column name" error.
- **Column values** (data stored in your rows) are passed through
unchanged.  Full UTF-8 strings, emoji, and any valid Perl string are
safe as values in criteria and are stored and returned as-is.  The
exact encoding depends on your backend:
    - **SQLite / DBI backends** -- DBD::SQLite stores strings as
    UTF-8 internally.  Enable `sqlite_unicode => 1` in your DSN
    or connect attributes if you want Perl to receive decoded Unicode
    scalars rather than raw UTF-8 bytes.
    - **CSV / PSV / TSV** -- Text::CSV and DBD::CSV pass bytes
    through unchanged.  If your file is UTF-8-encoded, strings are
    returned as UTF-8 byte strings.  You may need to decode them with
    `Encode::decode('UTF-8', $val)` in your application.
    - **JSON** -- JSON::MaybeXS decodes JSON strings to Perl Unicode
    scalars automatically.  UTF-8 and emoji in JSON values are fully
    supported.
    - **XML** -- XML::Simple decodes the file with the XML encoding
    declaration.  UTF-8 encoded XML (the default) produces Perl Unicode
    scalars.
    - **XLSX** -- Spreadsheet::ParseXLSX decodes strings to Perl
    Unicode scalars.  Emoji in cell values are fully supported.
    - **HTML URL** -- LWP::UserAgent fetches bytes; HTML::TableExtract
    returns them as-is.  Decode with Encode if the page is UTF-8.
- **File paths** are handled by `File::Spec::catfile` and work
on any platform.  On Linux and macOS, UTF-8 directory and file names
are fully supported.

## Known Limitations

- **Read-only.**  No INSERT, UPDATE, or DELETE is provided.  `execute()`
runs raw read-only SQL.
- **Default CSV separator is `!`**, not `,`, for historical reasons.
Pass `sep_char => ','` for standard RFC 4180 files.
- **Primary-key column is named `entry`**, not `key`, because `key`
is a SQL reserved word.  Override with the `id` parameter.
- **XML slurp is limited.**  Only simple flat XML structures are supported
in slurp mode.  Multi-key or deeply nested documents will croak.
Force SQL mode with `max_slurp_size => 0` if slurp fails.
- **Unique key assumption in slurp mode.**  Duplicate values in the key
column silently overwrite earlier rows.  Disable slurp with
`max_slurp_size => 0` if duplicates are expected.
- **BerkeleyDB does not support joins or the chained query builder.**
- **Column names must be valid SQL identifiers** (letters, digits,
underscores, and a single dot for `table.column` join notation).
Other characters will cause a croak.
- **count() cache is opportunistic.**  Count results are served from cache
only when a prior `selectall_arrayref()` or `count()` call with the
same criteria has already populated it.

## See Also

- [Database::Abstraction::Query](https://metacpan.org/pod/Database%3A%3AAbstraction%3A%3AQuery) - chained query builder
- [Configure an Object at Runtime](https://metacpan.org/pod/Object%3A%3AConfigure)
- [JSON::MaybeXS](https://metacpan.org/pod/JSON%3A%3AMaybeXS) - JSON backend (optional; install for `.json` support)
- [Test Dashboard](https://nigelhorne.github.io/Database-Abstraction/coverage/)

## Formal Specification

The following section uses Z-notation to describe the key state
transitions and invariants for the module.  Z is a formal
specification language based on set theory and predicate logic.
Unicode is used here as permitted by the documentation skill.

### Database Object State

```
State == [
    data       : TABLE_NAME --> (ENTRY --> ROW) | seq ROW | undef,
    dbh        : TABLE_NAME --> DBI_HANDLE | undef,
    type       : Str | undef,
    base_crit  : CRITERIA,
    _columns   : seq Str | undef,
    _schema    : NAME --> COL_INFO | undef,
    _destroyed : Bool
]

Initial state S0 == [
    data?        = undef,
    dbh?         = undef,
    type?        = undef,
    base_crit?   = {},
    _columns?    = undef,
    _schema?     = undef,
    _destroyed?  = false
]
```

### \_Open\_Table

```
_open_table ___________________________________________
DeltaState
class? : CLASS
dir?   : DIRPATH | undef
────────────────────────────────────────────────────
pre:  _destroyed = false
pre:  dbh(table) = undef AND data(table) = undef
post: (EXISTS f : FILE_FORMATS WITH f.probe(dir) = true
       IMPLIES (f.slurp? IMPLIES data'(table) != undef)
            AND (f.dbi?  IMPLIES dbh'(table)  != undef))
```

### Selectall\_Arrayref

```
selectall_arrayref __________________________________
XiState  (read-only; state unchanged except cache)
criteria? : CRITERIA
────────────────────────────────────────────────────
pre:  _destroyed = false
result == (data != undef AND simple(criteria)
           IMPLIES { r : data | match(r, criteria) }
           ELSE    dbh != undef
           IMPLIES SQL_SELECT(dbh, criteria))
post: result : seq ROW UNION {undef}
```

### Aggregate

```
aggregate ____________________________________________
XiState
by?       : seq COLNAME
measures? : seq MEASURE_SPEC
criteria? : CRITERIA
────────────────────────────────────────────────────
pre:  measures != empty
pre:  FORALL m : measures WITH m.as NOT IN by
pre:  sort_by != undef IMPLIES sort_by IN ran(by) UNION aliases(measures)
result == (dbh != undef
           IMPLIES SQL_GROUP_BY(dbh, by, measures, criteria)
           ELSE    PERL_FOLD(selectall_arrayref(criteria), by, measures))
post: result : seq ROW (keys = by UNION aliases(measures))
post: FORALL r : result WITH
          r.count != undef IMPLIES r.count >= 0
        AND r.sum = undef IMPLIES no rows in that group
```

### Destroy

```
DESTROY ______________________________________________
DeltaState
────────────────────────────────────────────────────
post: _destroyed' = true
post: dbh'      = undef  (DBI handles disconnected)
post: data'     = undef
post: _columns' = undef
post: _schema'  = undef
```

## State Diagram

The following ASCII diagram shows every valid state the object can
occupy and the transitions between them.  Transitions are labelled
with the method call or event that triggers them.  Actions are shown
in parentheses.

```
.-----------------------------------------------------------.
|                  S1: CONSTRUCTED                          |
|  dbh=undef, data=undef, type=undef                        |
'---.-------.-------.-------.-------.-----.---.-------------'
    |       |       |       |       |     |   |
    | first query call triggers _open_table()
    |       |       |       |       |     |   |
    v       v       v       v       v     v   v
.------. .------. .------. .------. .--. .--. .--.
| S2   | | S3   | | S4   | | S5   | |S6| |S7| |S8|
| DBI  | | CSV  | | XML  | | JSON | |Xls| |Bk| |Dp|
'---^--' '--^---' '--^---' '--^---' '-^-' '-^-' '-^-'
    |       |       |       |       |   |   |   |
    |       |       '-------'-------'   |   |   |
    |       |         slurp path        |   |   |
    |       '-----------+---------------'   |   |
    |                   |                   |   |
    |         (data populated,              |   |
    |          _columns/_schema undef)      |   |
    |                                       |   |
    | (dbh populated,                       |   |
    |  data undef)                          |   |
    |                                       |   |
    +-------+--------+-----------+----------+   |
            |        |           |              |
      selectall   fetchrow    aggregate      fetchrow
      count       _hashref    query->all     _hashref
      each_row    AUTOLOAD    AUTOLOAD        AUTOLOAD
            |        |           |              |
            v        v           v              v
.-----------------------------------------------------------.
|                  (result returned to caller)              |
|          state is unchanged; only cache may be updated    |
'-----------------------------------------------------------'
                            |
                DESTROY (or object goes out of scope)
                            |
                            v
.-----------------------------------------------------------.
|                  S_DEAD: DESTROYED                        |
|  _destroyed=true; all handles disconnected;               |
|  temp files unlinked; _remote_tmpdir removed              |
'-----------------------------------------------------------'

State labels:
  S1  CONSTRUCTED  - new() returned; no I/O done yet (lazy open)
  S2  DBI          - live DBI handle (SQLite file, DSN, DBD::CSV)
  S3  CSV_SLURP    - small CSV/PSV/TSV/gzip-CSV slurped into RAM hash
  S4  XML_SLURP    - XML file slurped into RAM hash
  S5  JSON_SLURP   - JSON file slurped into RAM hash
  S6  XLSX_SLURP   - XLSX file slurped (type='XLSX', no DBI handle)
  S7  BERKELEYDB   - BerkeleyDB key-value file open (berkeley flag set)
  S8  DEEP_SLURP   - DBM::Deep file slurped into plain RAM hash

Blocked transitions (croak before state changes):
  S1 -> any  if constructor arguments are invalid
             (unsafe id/table/host/url)
  S* -> S*   aggregate() with join on S3/S4/S5/S6/S7/S8
             (no DBI handle)
  S7 -> S*   join() or select()-projection on BerkeleyDB
  S8 -> S*   join() on DBM::Deep
```

## License and Copyright

Copyright 2015-2026 Nigel Horne.

Usage is subject to the GPL2 licence terms.
If you use it,
please let me know.
