package DBIx::Fast;

use v5.38;
use Object::Pad;

our $VERSION = '0.17';

use DBI;
use DBIx::Fast::Connector;
use DBIx::Fast::Result;
use DBIx::Fast::SQL;
# SQL::Abstract is required lazily in ADJUST, only when abstract => 1.

use Carp 'croak';
use Time::HiRes 'time';
use Scalar::Util 'weaken';


# The query and CRUD methods (and the statement state sql/p/results/last_id/
# last_sql they drive) are composed in from the DBIx::Fast::SQL role - see
# that module. Object::Pad classes cannot span files.
class DBIx::Fast :does(DBIx::Fast::SQL) {

  # Public fields. $db stays writable; the driver-aware SQL::Abstract
  # instance is :reader only, so callers cannot clobber it (ranks 17/27).
  field $db       :accessor = undef;
  field $Q        :reader   = undef;

  # Internal state with readers
  field $_args       :reader(args)       = undef;
  field $_dbd        :reader(dbd)        = undef;
  field $_dsn        :reader(dsn)        = undef;
  field $_errors     :reader(errors)     = [];
  field $_last_error :reader(last_error) = undef;

  # Lazy subsystems
  field $_schema;
  field $_transaction;
  field $_profiler;
  field $_query_tracker;
  field $_cache;
  field %_extensions;


  # Constructor params
  field $_init_db         :param(db)                 = undef;
  field $_init_dsn        :param(dsn)                = '';
  field $_init_SQLite     :param(SQLite)             = undef;
  field $_init_driver     :param(driver)             = '';
  field $_init_user       :param(user)               = '';
  field $_init_password   :param(password)           = '';
  field $_init_host       :param(host)               = '';
  field $_init_port       :param(port)               = undef;
  field $_init_tn         :param(tn)                 = 0;
  # Vestigial: accepted (and stored in args) for backward compatibility, but
  # has no effect. Identifier quoting is automatic and driver-aware (see the
  # SQL::Abstract quote_char below and _quote_id). The old on_connect used to
  # call $dbh->quote($quote) - which quotes a VALUE and discards it, a no-op.
  field $_init_quote      :param(quote)              = '';
  field $_init_trace      :param(trace)              = '';
  field $_init_profile    :param(profile)            = '';
  field $_init_abstract   :param(abstract)           = 1;
  field $_init_RaiseError :param(RaiseError)         = undef;
  field $_init_PrintError :param(PrintError)         = undef;
  field $_init_AutoCommit :param(AutoCommit)         = undef;
  field $_init_mysql_utf8 :param(mysql_enable_utf8)  = 0;
  field $_init_Error      :param(Error)              = undef; # Compat: use RaiseError/PrintError
  field $_init_max_errors :param(max_errors)         = 100;
  field $_init_errors_redact :param(errors_redact)   = 0;
  field $_init_cache      :param(cache)              = undef;
  # Profiler output routing (see DBIx::Fast::Output): 'text' | 'json' | coderef,
  # plus an optional filehandle. Applied to both tracker() and profiler().
  field $_init_profile_output    :param(profile_output)    = undef;
  field $_init_profile_output_fh :param(profile_output_fh) = undef;

  ADJUST {
    # SQLite shortcut
    if ($_init_SQLite) {
      $_init_db     //= $_init_SQLite;
      $_init_driver   = 'SQLite';
    }

    # Build processed config
    $_args = {
	      DBI => {
		      AutoCommit => $_init_AutoCommit // 1,
		      RaiseError => $_init_RaiseError // 1,
		      PrintError => $_init_PrintError // 0
		     },
	      Auth => {
		       user     => $_init_user,
		       password => $_init_password,
		       host     => $_init_host,
		       port     => $_init_port,
		      },
	      tn       => $_init_tn,
	      db       => $_init_db // '',
	      dsn      => $_init_dsn,
	      driver   => $_init_driver,
	      quote    => $_init_quote,
	      trace    => $_init_trace,
	      profile  => $_init_profile,
	      abstract => $_init_abstract,
	     };
	
    # SQLite file check
    if ($_init_SQLite) {
      $self->Exception("No DB Found : $_init_SQLite")
	unless -e $_init_SQLite;
    }

    unless ($_args->{dsn} || $_args->{db}) {
      $self->Exception("Need a DSN or Host");
    }

    $_dsn = $_args->{dsn}
      ? $self->_check_dsn($_args->{dsn})
      : $self->_make_dsn($_args);

    # Map the UTF-8 flag to the driver-appropriate attribute now that $_dbd is
    # resolved: DBD::MariaDB ignores mysql_enable_utf8 (it honours
    # mariadb_enable_utf8mb4), so applying it driver-blind silently fails.
    if ($_init_mysql_utf8) {
      if    ($_dbd eq 'MariaDB') { $_args->{DBI}{mariadb_enable_utf8mb4} = 1 }
      elsif ($_dbd eq 'mysql')   { $_args->{DBI}{mysql_enable_utf8mb4}   = 1 }
    }

    if ($_args->{abstract}) {
      require SQL::Abstract;
      my $qchar =
          ($_dbd eq 'MariaDB' || $_dbd eq 'mysql') ? '`' : '"';
      $Q = SQL::Abstract->new(quote_char => $qchar, name_sep => '.');
    }

    # Per-connection setup, run on every (re)connect with the fresh handle so
    # HandleError/trace/quote/Profile survive a reconnect (rank 9). Capture only
    # plain lexicals (and a WEAKENED $self) - capturing a field or a strong
    # $self here would re-form the reference cycle weaken() is meant to break
    # (rank 2). Uses the passed $dbh, never $db->dbh, to avoid re-entering the
    # Connector during a (re)connect.
    my $weak_self = $self;
    weaken($weak_self);
    my $trace_opt  = $_args->{trace};
    my $profile    = $_args->{profile};

    my $on_connect = sub ($dbh) {
      return unless $dbh;
      $dbh->{HandleError} = sub {
        $weak_self->set_error($DBI::err, $DBI::errstr) if $weak_self;
        return 0;             # Let DBI continue with RaiseError/PrintError
      };
      $dbh->trace($trace_opt, 'dbix-fast-trace') if $trace_opt;
      $dbh->{Profile} = $profile . "/DBI::ProfileDumper/File:dbix-fast-$$.log"
        if $profile;
    };

    # Handle db param: connector object or string
    if ($_init_db && ref($_init_db)) {
      $db = $_init_db;
    } else {
      $db = DBIx::Fast::Connector->new(
        dsn        => $_dsn,
        user       => $_args->{Auth}->{user},
        pass       => $_args->{Auth}->{password},
        attrs      => $_args->{DBI},
        on_connect => $on_connect,
      );
    }

    # Apply now to the initial handle (and to an injected connector, whose
    # reconnects we do not control).
    $on_connect->($db->dbh);

    $self->schema->_load_tables_name if $_args->{tn};
  }

  # Setters for internal state (needed by tests and internal methods)
  method _set_args ($val) { $_args = $val }
  method _set_dbd  ($val) { $_dbd  = $val }
  method _set_dsn  ($val) { $_dsn  = $val }

  # Lazy subsystem accessors (loaded on demand)
  method schema {
    $_schema //= do {
      require DBIx::Fast::Schema;
      DBIx::Fast::Schema->new(dbix => $self);
    };
  }

  method transaction {
    $_transaction //= do {
      require DBIx::Fast::Transaction;
      DBIx::Fast::Transaction->new(dbix => $self);
    };
  }

  # Only the profile_output* options the caller actually set, so the
  # DBIx::Fast::Output role defaults apply otherwise.
  method _output_opts {
    return (
      defined $_init_profile_output
        ? (output => $_init_profile_output) : (),
      defined $_init_profile_output_fh
        ? (output_fh => $_init_profile_output_fh) : (),
    );
  }

  method tracker {
    $_query_tracker //= do {
      require DBIx::Fast::Profiler;
      DBIx::Fast::Profiler->new(dbix => $self, auto_print => 0,
                                $self->_output_opts);
    };
  }

  # Non-vivifying tracker lookup for the SQL role's _track: going through
  # tracker() would instantiate the profiler and turn tracking on for every
  # query from then on.
  method _active_tracker {
    return $_query_tracker && $_query_tracker->enabled
      ? $_query_tracker : undef;
  }

  # Returns the cache instance if `cache => {...}` was passed at construction,
  # otherwise undef. Caching is opt-in to keep zero-overhead for users that
  # don't need it.
  method cache {
    return $_cache if $_cache;
    # Truthiness, not defined-ness: `cache => 0` / `''` (a common "disable"
    # idiom, e.g. `cache => $enabled ? {...} : 0`) must NOT switch caching on.
    return undef unless $_init_cache;

    require DBIx::Fast::Cache;
    my %opts = ref $_init_cache eq 'HASH' ? %{$_init_cache} : ();
    $_cache = DBIx::Fast::Cache->new(%opts);
  }

  # Returns a query proxy that caches results. If cache is disabled,
  # the proxy still works but transparently bypasses the cache.
  method cached (%opts) {
    require DBIx::Fast::Cached;
    DBIx::Fast::Cached->new(dbix => $self, %opts);
  }

  method profiler {
    return $_profiler if $_profiler;

    my $driver        = $_dbd;
    my $profile_class = "DBIx::Fast::Profile::$driver";

    (my $_pf = "$profile_class.pm") =~ s|::|/|g; eval { require $_pf };

    if ($@) {
      warn "No profile support for $driver, using base profile";
      require DBIx::Fast::Profile::Base;
      $_profiler = DBIx::Fast::Profile::Base->new(dbix => $self,
                                                  $self->_output_opts);
      return $_profiler;
    }

    $_profiler = $profile_class->new(dbix => $self, $self->_output_opts);
    $_profiler->init();

    return $_profiler;
  }

  # Transaction helper
  method txn ($code, $options = undef) {
    return $self->transaction->do($code, $options);
  }

  method load_extension ($extension) {
    my $module = "DBIx::Fast::$extension";

    (my $_mf = "$module.pm") =~ s|::|/|g; eval { require $_mf };
    $self->Exception("Error: load_extension => $@") if $@;

    my $attr = lc($extension);

    # Check if there's already a method for this
    if ($self->can($attr)) {
      return $self->$attr;
    }

    return $_extensions{$attr} if exists $_extensions{$attr};

    $_extensions{$attr} = $module->new(dbix => $self);
    return $_extensions{$attr};
  }

  method now () {
    my ($sec, $min, $hour, $mday, $mon, $year) = localtime;

    return sprintf(
		   "%04d-%02d-%02d %02d:%02d:%02d",
		   $year + 1900, $mon + 1, $mday, $hour, $min, $sec
		  );
  }

  # Like now() but in UTC (gmtime). Use when the DB session timezone differs
  # from the host's, so stored timestamps stay comparable to server-side UTC.
  method now_utc () {
    my ($sec, $min, $hour, $mday, $mon, $year) = gmtime;

    return sprintf(
		   "%04d-%02d-%02d %02d:%02d:%02d",
		   $year + 1900, $mon + 1, $mday, $hour, $min, $sec
		  );
  }

  # Redact quoted literal values when errors_redact is on. Targets the common
  # "Duplicate entry 'X' for key 'Y'" / "data X" patterns that leak PII or PCI
  # data (card tokens, emails, etc.) into logs. Shared by set_error and
  # Exception so both reporting paths honor the same policy.
  method _redact ($msg) {
    return $msg unless $_init_errors_redact && defined $msg;
    $msg =~ s/'[^']*'/'<redacted>'/g;
    $msg =~ s/"[^"]*"/"<redacted>"/g;
    # MySQL/MariaDB quote identifiers (and some values) with backticks.
    $msg =~ s/`[^`]*`/`<redacted>`/g;
    return $msg;
  }

  method set_error ($id, $error_msg) {
    $error_msg = $self->_redact($error_msg);

    my $error = { id => $id, error => $error_msg, time => time() };

    push @{$_errors}, $error;

    # FIFO cap: drop oldest entries when above the configured ceiling. Prevents
    # unbounded growth in long-running workers that hit recurring DB errors.
    if ($_init_max_errors > 0
        && scalar @{$_errors} > $_init_max_errors) {
      splice @{$_errors}, 0,
             scalar(@{$_errors}) - $_init_max_errors;
    }

    $_last_error =
      qq{$error->{time} - [$error->{id}] - $error->{error}};
  }

  # DSN handling

  method _Driver_dbd ($dbd_name) {
    $self->Exception("Error DBD Driver") unless $dbd_name;

    for my $d (qw(SQLite Pg MariaDB mysql)) {
      if (lc($dbd_name) eq lc($d)) {
	$_dbd = $d;
	last;
      }
    }

    $self->Exception("Error DBD Driver : $dbd_name") unless $_dbd;
  }

  method _dsn_dbi ($dsn_str) {
    # A native dbi:DRIVER:... DSN. DBI itself is the authority on the
    # driver-specific remainder, so only confirm the 'dbi' prefix and resolve
    # the driver. Split with a limit of 3 so the rest stays intact (e.g.
    # SQLite's "dbi:SQLite::memory:", where the database part contains colons).
    my ($dbi, $driver) = split /:/, $dsn_str, 3;

    $self->Exception("Invalid DSN (expected dbi:DRIVER:...): $dsn_str")
      unless defined $dbi && lc($dbi) eq 'dbi'
          && defined $driver && length $driver;

    $self->_Driver_dbd($driver);

    return $dsn_str;
  }

  method _check_dsn ($dsn_str) {
    # Case-insensitive: "DBI:..." is as valid as "dbi:..." for DBI.
    return $self->_dsn_dbi($dsn_str) if $dsn_str =~ /^dbi/i;
    return $self->_dsn_to_dbi($dsn_str);
  }

  method _make_dsn ($args) {
    $self->Exception("DSN Driver: Not defined") unless $args->{driver};

    $self->_Driver_dbd($args->{driver});

    return 'dbi:SQLite:dbname=' . $args->{db}
      if $args->{driver} eq 'SQLite';

    # host/port live under Auth in the constructed config, but accept them at
    # the top level too (direct callers / tests).
    my $host = $args->{host} // ($args->{Auth} && $args->{Auth}{host});
    my $port = $args->{port} // ($args->{Auth} && $args->{Auth}{port});

    $self->Exception("DSN Host: Not defined") unless $host;
    $self->Exception("DSN DB: Not defined")   unless $args->{db};

    # Proper ';'-separated name=value DSN. The old "...:database=db:host" form
    # folded the host into the dbname (so DBD drivers ignored it) and could not
    # express a port.
    my $dsn = "dbi:$_dbd:dbname=$args->{db};host=$host";
    $dsn .= ";port=$port" if $port;
    return $dsn;
  }

  method _dsn_to_dbi ($dsn_str) {
    my $URI;

    # SQLite
    if ($dsn_str =~ /^sqlite:\/\/\/(.*)$/) {
      $_dbd = 'SQLite';
      return 'dbi:SQLite:dbname=' . $1;
    }

    # userinfo (user[:pass]@) is optional: a credential-less URI like
    # "mariadb://localhost/db" is valid for socket / peer authentication.
    ($URI->{schema}, $URI->{UI}, $URI->{connect}, $URI->{db}) =
      $dsn_str =~ m{^([^:]+)://(?:([^@]+)@)?([^/]+)/(.+)$};

    $self->Exception("_dsn_to_dbi : schema") unless $URI->{schema};

    # The driver is resolved from the URI scheme at the bottom of this method
    # (postgres/postgresql -> Pg, mariadb -> MariaDB, mysql/mysqlx -> mysql).
    # Do NOT call _Driver_dbd($schema) here: it doesn't know those scheme aliases
    # and, on a fresh construction (no $_dbd yet), would throw "Error DBD Driver"
    # for a perfectly valid "postgres://" / "postgresql://" / "mysqlx://" DSN.
    $self->Exception("_dsn_to_dbi : connect") unless $URI->{connect};

    $URI->{connect} =~ /:/
      ? ($URI->{host}, $URI->{port}) = split ':', $URI->{connect}
      : $URI->{host} = $URI->{connect};

    # UserInfo (absent for socket/peer auth - leave Auth untouched then).
    if (defined $URI->{UI}) {
      if ($URI->{UI} =~ /:/) {
        # Limit to two fields so a password containing ':' is kept intact.
        ($URI->{user}, $URI->{password}) = split /:/, $URI->{UI}, 2;

        $_args->{Auth}->{user}     = $URI->{user};
        $_args->{Auth}->{password} = $URI->{password};
      } else {
        $URI->{user} = $URI->{UI};
      }
    }

    $self->Exception('_dsn_to_dbi : No DB value') unless $URI->{db};

    if ($URI->{db} =~ s/^(.*)\?(.*)$/$1/) {
      ($URI->{attribute}, $URI->{value}) = split '=', $2;
    }

    if ($dsn_str =~ /^(postgres|postgresql):/) {
      $_dbd = 'Pg';
    } elsif ($dsn_str =~ /^(mariadb):/) {
      $_dbd = 'MariaDB';
    } elsif ($dsn_str =~ /^(mysql|mysqlx):/) {
      $_dbd = 'mysql';
    } else {
      # Never echo the raw DSN: for URI-style DSNs it carries user:password.
      (my $redacted = $dsn_str) =~ s{//[^@/]+@}{//<redacted>@};
      $self->Exception("_dsn_to_dbi : $redacted");
    }

    $URI->{DSN} = sprintf('dbi:%s:dbname=%s;host=%s%s',
			  $_dbd, $URI->{db}, $URI->{host},
			  $URI->{port} ? ";port=$URI->{port}" : ''
			 );

    return $URI->{DSN};
  }

  method _profile ($stat) {
    $stat .= "/DBI::ProfileDumper/";
    $stat .= qq{File:dbix-fast-$$.log};

    $db->dbh->{Profile} = $stat;
  }

  # Validate identifier
  method _safe_id ($name) {
    $self->Exception("Invalid identifier")
      unless defined $name && $name =~ /^[a-zA-Z_][a-zA-Z0-9_.]*$/;

    return $name;
  }

  # Validate and quote identifier (defense-in-depth)
  method _quote_id ($name) {
    $self->_safe_id($name);
    return $db->dbh->quote_identifier($name);
  }

  # Validate and quote a possibly-qualified identifier ("schema.table",
  # "t.col") by quoting each dot-separated segment, so quote_identifier does not
  # wrap the whole dotted name as one literal identifier ("schema.table").
  method _quote_ident_path ($name) {
    $self->_safe_id($name);
    my $dbh = $db->dbh;
    return join '.', map { $dbh->quote_identifier($_) } split /\./, $name;
  }

  # Validate a CRUD data/WHERE spec before it reaches SQL::Abstract, which
  # interpolates hash keys verbatim (its injection_guard only blocks ';'). Must
  # cover EVERY shape SQL::Abstract accepts, not just HASH: an arrayref WHERE
  # (OR-of-conditions) or a scalarref WHERE (literal SQL) would otherwise slip
  # past the operator whitelist entirely.
  method _safe_columns ($spec) {
    my $ref = ref $spec;
    if ($ref eq 'HASH') {
      $self->_safe_id($_)      for keys %$spec;
      $self->_safe_op_tree($_) for values %$spec;
    }
    elsif ($ref eq 'ARRAY') {
      # OR-list: hashref/arrayref conditions, or -and/-or logic tokens.
      for my $el (@$spec) {
        next if !ref $el && defined $el && $el =~ /^-\w+$/;
        $self->_safe_columns($el);
      }
    }
    elsif ($ref eq 'SCALAR' || $ref eq 'REF') {
      # A literal-SQL WHERE ( \'1=1' ) from a CRUD helper can never be trusted.
      $self->Exception("Literal-SQL WHERE is not allowed in CRUD methods");
    }
    # plain scalar / undef: a bind value, nothing to validate.
  }

  method _safe_op_tree ($val) {
    state %VALID_OPS = map { $_ => 1 }
      ('=', '!=', '<>', '<', '<=', '>', '>=',
       'LIKE', 'NOT LIKE', 'IN', 'NOT IN',
       'BETWEEN', 'NOT BETWEEN', 'IS', 'IS NOT');

    if (ref $val eq 'HASH') {
      for my $op (keys %$val) {
        # SQL::Abstract's own -dashed ops (-and, -or, -in, -ident, ...) are
        # single \w tokens and cannot carry an injection payload.
        $self->Exception("Invalid operator: $op")
          unless $VALID_OPS{uc $op} || $op =~ /^-\w+$/;
        $self->_safe_op_tree($val->{$op});
      }
    }
    elsif (ref $val eq 'ARRAY') {
      $self->_safe_op_tree($_) for @$val;
    }
  }


  # Query methods












  # Invalidate any cached query tagged with the given table name. No-op
  # when caching is disabled. The convention is that users tag cached
  # reads with `tag => $table` so writes propagate transparently.
  method _cache_invalidate_table ($table) {
    return unless $_cache || defined $_init_cache;
    my $cache = $self->cache or return;
    $cache->invalidate($table);
  }

  # Public cache invalidation by tag(s). Use after mutations that bypass the
  # CRUD auto-invalidation: raw exec()/query()/execute() writes, or reads that
  # span several tables (which must be tagged with, and invalidated for, every
  # table involved). No-op when caching is disabled.
  method invalidate (@tags) {
    my $cache = $self->cache or return 0;
    my $n = 0;
    $n += $cache->invalidate($_) for @tags;
    return $n;
  }









  # Named parameter methods


  # Execute helpers


  # Validation

  method TableName ($table) {
    $self->Exception("Not defined table") unless $table;

    # Format validation is unconditional (same pattern as _safe_id): count()
    # and upsert() already rejected malformed names downstream, while
    # insert()/update() let them through to SQL::Abstract - one rule for all.
    $self->Exception("TableName not valid: $table")
      unless $table =~ /^[a-zA-Z_][a-zA-Z0-9_.]*$/;

    return $table unless $_args->{tn};

    $self->Exception("TableName not exist: $table")
      unless $self->schema->tables->{$table};

    return $table;
  }

  method Exception ($msg) {
    # Always croak. Validation/usage errors (e.g. _safe_id "Invalid identifier")
    # must never be silently swallowed: a silent return used to let a rejected
    # identifier flow on and become an injection vector when the handle was
    # configured with RaiseError=0 & PrintError=0.
    #
    # The message honors errors_redact, and last_error is attached only when
    # RECENT (likely causal context) - a DB error from minutes ago would only
    # mislead the diagnosis and could leak stale data into a fresh exception.
    my $full = "Exception: " . ($self->_redact($msg) // '');

    my $last_t = @{$_errors} ? $_errors->[-1]{time} : 0;
    $full .= " - Last error: $_last_error"
      if $_last_error && (time() - $last_t) < 5;

    croak $full;
  }
}

1;

__END__

=pod

=head1 NAME

DBIx::Fast - DBI fast & easy

=head1 SYNOPSIS

    use DBIx::Fast;

    # Connect via a DBI DSN
    my $db = DBIx::Fast->new(
        dsn      => 'dbi:MariaDB:dbname=mydb;host=localhost',
        user     => 'root',
        password => 'secret',
    );

    # Connect via a URI (postgres|postgresql|mariadb|mysql|sqlite)
    my $db = DBIx::Fast->new( dsn => 'mariadb://root:secret@localhost:3306/mydb' );

    # SQLite shortcut
    my $db = DBIx::Fast->new( SQLite => '/path/to/db.sqlite' );

    # Dependency injection
    my $db = DBIx::Fast->new( db => $connector, driver => 'SQLite' );

    # Queries
    $db->all('SELECT * FROM users WHERE active = ?', 1);
    my $rows = $db->results;

    my $user = $db->hash('SELECT * FROM users WHERE id = ?', $id);
    my $name = $db->val('SELECT name FROM users WHERE id = ?', $id);
    my @ids  = $db->flat('SELECT id FROM users');
    my $total = $db->count('users', { active => 1 });

    # CRUD
    $db->insert('users', { name => 'Alice', status => 1 }, time => 'created_at');
    $db->up('users', { name => 'Bob' }, { id => 1 });
    $db->up('users', { name => 'Bob' }, { id => 1 }, 'updated_at');
    $db->delete('users', { id => 1 });

    # Transactions
    $db->txn(sub {
        $db->insert('orders', { total => 100 });
        $db->insert('order_items', { order_id => $db->last_id, product => 'Widget' });
    });

    # Named parameters
    $db->execute('SELECT * FROM users WHERE name = :name', { name => 'Alice' });

    # Row iterator (does not load all rows into memory)
    my $rs = $db->query('SELECT * FROM logs ORDER BY id DESC LIMIT 1000');
    while (my $row = $rs->hash) {
        process($row);
    }

    # Transactions with automatic deadlock retry, savepoints and isolation
    $db->txn(sub {
        $db->insert('accounts', { id => 1, balance => 100 });
        $db->transaction->savepoint('sp1');
        $db->up('accounts', { balance => 90 }, { id => 1 });
    }, { max_retries => 3, isolation => 'SERIALIZABLE' });

    # Cached reads (TTL + tag invalidation; CRUD writes auto-invalidate the table)
    my $cdb = DBIx::Fast->new( SQLite => 'app.db', cache => { default_ttl => 60 } );
    my $row = $cdb->cached(ttl => 60, tag => 'users')
                  ->hash('SELECT * FROM users WHERE id = ?', $id);

    # Query profiling
    $db->tracker;                                 # enable tracking
    $db->all('SELECT * FROM users');
    my $stats = $db->tracker->get_stats;          # timings, slow queries, by type

=head1 DESCRIPTION

DBIx::Fast is a lightweight database access layer built on L<DBI> and
L<SQL::Abstract>. It sits between a bare DBI handle and a full ORM: you keep
writing SQL, but the everyday plumbing - connection resilience, transactions,
result shaping, caching, profiling and identifier safety - is handled for you,
behind one small API that works the same across SQLite, MariaDB, MySQL and
PostgreSQL.

Built with L<Object::Pad>; requires Perl v5.38 or later.

=head1 FEATURES

=over 4

=item * B<Multi-driver, dialect-aware> - one API over SQLite, MariaDB, MySQL and
PostgreSQL. Driver differences (identifier quoting, C<upsert> syntax,
last-insert id, isolation-level SQL, schema introspection) are handled
internally.

=item * B<Resilient connections> - fork-safe handles with lazy connect,
throttled server-side C<ping> to detect dropped connections, automatic
reconnect, and a guard that fails loudly (rather than silently committing) if a
connection is lost mid-transaction. See L<DBIx::Fast::Connector>.

=item * B<Transactions that retry> - C<txn> runs a block in a transaction with
automatic B<deadlock / lock-wait retry> (detected by driver error code), nested
transactions, named savepoints and per-transaction isolation levels. See
L<DBIx::Fast::Transaction>.

=item * B<Result caching> - optional in-process (or L<CHI>-backed) cache of query
results with TTL and tag-based invalidation; CRUD writes auto-invalidate the
affected table. See L<DBIx::Fast::Cache> and L<DBIx::Fast::Cached>.

=item * B<Query profiling> - a built-in tracker (timings, slow-query detection,
per-type statistics) plus driver-native diagnostics such as C<EXPLAIN> and
index / connection analysis. See L<DBIx::Fast::Profiler>,
L<DBIx::Fast::Profile::MariaDB>, L<DBIx::Fast::Profile::mysql> and
L<DBIx::Fast::Profile::Pg>.

=item * B<Security-conscious> - values always go through placeholders;
identifiers are validated and quoted; C<count> operators, isolation levels and
savepoint names are whitelisted; error messages can optionally redact quoted
literals (PII / PCI). See L</SECURITY>.

=item * B<Flexible result shapes> - C<all> / C<hash> / C<val> / C<flat> /
C<array>, a memory-efficient row iterator (C<query>), and both positional
(C<?>) and named (C<:name>) parameters.

=back

=head2 When to use it

DBIx::Fast is a good fit when you want to write SQL directly but not
re-implement connection handling, transactions, caching and safety every time.
It is B<not> an ORM: there are no result classes, relationships or migrations.
If you need to map an object graph, reach for a full ORM instead (see
L</SEE ALSO>).

=head2 Supported drivers

SQLite (L<DBD::SQLite>), MariaDB (L<DBD::MariaDB>), MySQL (L<DBD::mysql>) and
PostgreSQL (L<DBD::Pg>). The test suite runs against SQLite always, and against
live MariaDB and PostgreSQL when C<DBIX_FAST_TEST_MARIADB> /
C<DBIX_FAST_TEST_PG> are set.

=head1 CONSTRUCTOR

=head2 new

    my $db = DBIx::Fast->new(%args);

Accepted parameters:

=over 4

=item C<dsn> - a native DBI DSN (C<dbi:DRIVER:...>, case-insensitive) or a
URI. URI userinfo is optional, so a credential-less
C<mariadb://localhost/db> (socket / peer authentication) is accepted as well
as C<mariadb://user:pass@host:port/db>.

=item C<SQLite> - Path to SQLite database file (shortcut). For an in-memory
database use C<< SQLite => ':memory:' >> or the DSN C<dbi:SQLite::memory:>.

=item C<db> - Database name string or pre-built L<DBIx::Fast::Connector> object

=item C<driver> - Database driver: SQLite, Pg, MariaDB, mysql

=item C<user>, C<password>, C<host>, C<port> - Connection credentials

=item C<RaiseError>, C<PrintError>, C<AutoCommit> - DBI attributes (defaults: 1, 0, 1)

=item C<mysql_enable_utf8> - Enable UTF-8 on MySQL/MariaDB (mapped to the
driver-appropriate attribute: C<mariadb_enable_utf8mb4> / C<mysql_enable_utf8mb4>).
Default: 0

=item C<tn> - Enable table-name validation against the schema (default: 0)

=item C<abstract> - Enable L<SQL::Abstract> for CRUD (default: 1)

=item C<cache> - Enable result caching: a hashref of L<DBIx::Fast::Cache>
options (e.g. C<< { default_ttl => 60, max_size => 1000 } >>). Omit to disable.
See L</cached>.

=item C<max_errors> - Cap on the retained error history, FIFO (default: 100)

=item C<errors_redact> - Redact quoted literals in captured error messages,
for PII / PCI (default: 0)

=item C<profile_output> - Output routing for the profiler subsystem, applied
to both L</tracker> and L</profiler>: C<'text'> (default, formatted reports),
C<'json'> (one JSON line per event) or a coderef called as
C<< $cb->($event, $data, $meta) >>. See L<DBIx::Fast::Output>.

=item C<profile_output_fh> - Filehandle the text/json profiler output is
written to (default: the currently selected handle, normally STDOUT). For a
log file: C<< open my $fh, '>>', $path; chmod 0600, $path; >> - profiler
events carry cleartext SQL and bind values.

=item C<trace>, C<profile> - DBI tracing / profiling options

=back

=head1 ACCESSORS

=head2 db

L<DBIx::Fast::Connector> instance (read/write).

=head2 dbd

Database driver name: SQLite, Pg, MariaDB, or mysql (read-only).

=head2 dsn

Processed DSN string (read-only).

=head2 Q

L<SQL::Abstract> instance (read-only).

=head2 sql

Current SQL statement (read/write; part of the C<q>/C<make_sen> workflow).

=head2 last_sql

Last executed SQL statement (read-only).

=head2 p

Current bind parameters arrayref (read/write).

=head2 results

Last query result (read-only). Reflects only the most recent query operation.

=head2 last_id

Last insert id (read-only). Reflects only the most recent insert; the
authoritative value is the return value of C<insert> / C<insert_many>.

=head2 errors

All errors as arrayref (read-only).

=head2 last_error

Last error message string (read-only).

=head2 args

Processed constructor configuration hashref (read-only).

=head1 QUERY AND CRUD METHODS

All query methods (C<all>, C<flat>, C<hash>, C<val>, C<array>, C<query>,
C<count>, C<exec>, C<execute>) and CRUD methods (C<insert>, C<update>,
C<up>, C<delete>, C<upsert>, C<insert_many>, C<upsert_many>), together with
C<q>, C<make_sen> and C<execute_prepare>, are composed into this class from
the L<DBIx::Fast::SQL> role - the API is identical to previous releases,
only the reference documentation lives on that page.

=head1 RAW SQL

You are never limited to the CRUD helpers: write whatever SQL you like and
pick the method by what you want back. Values B<always> go in as C<?>
placeholders (bound, injection-safe) - never interpolate data into the
string yourself.

    # SELECT - pick the return shape:
    my $rows  = $db->all ('SELECT * FROM users WHERE age > ?', 18);   # arrayref of hashrefs
    my $row   = $db->hash('SELECT * FROM users WHERE id = ?', 42);    # one hashref
    my $count = $db->val ('SELECT COUNT(*) FROM users');              # one scalar
    my @names = $db->flat('SELECT name FROM users');                  # flat list of a column

    # Big result set - stream it row by row instead of slurping:
    my $rs = $db->query('SELECT id, name FROM users ORDER BY id');
    while (my $u = $rs->hash) { say "$u->{id}: $u->{name}" }

    # Anything else (DDL / INSERT / UPDATE / DELETE / vendor SQL):
    $db->exec('UPDATE users SET seen = ? WHERE id = ?', $now, 42);
    $db->exec('CREATE INDEX idx_users_email ON users (email)');

    # Named placeholders (:name) when positional ? is awkward:
    $db->execute('SELECT * FROM users WHERE name = :n AND age > :a',
                 { n => 'Alice', a => 18 });

The last statement, its bind values and result are always available via
C<< $db->sql >>, C<< $db->p >> and C<< $db->results >>.

B<Placeholders are for values, not identifiers.> Table and column names
cannot be bound; if one must be dynamic, validate it against your own
allow-list or quote it with C<< $db->_quote_id($name) >> - never build it
from raw user input. See L</SECURITY>.

Full reference for each method: L<DBIx::Fast::SQL>.

=head1 SUBSYSTEMS

=head2 schema

    my $schema = $db->schema;

Returns the L<DBIx::Fast::Schema> instance (lazy-loaded) for table
introspection.

=head2 transaction

    my $tx = $db->transaction;

Returns the L<DBIx::Fast::Transaction> instance (lazy-loaded).

=head2 txn

    $db->txn(sub { ... });
    $db->txn(sub { ... }, { max_retries => 5 });

Shortcut for C<< $db->transaction->do(...) >>. Executes a code block inside
a transaction with automatic commit/rollback and deadlock retry.

=head2 profiler

    my $profiler = $db->profiler;

Returns the driver-specific profile instance (L<DBIx::Fast::Profile::MariaDB>,
L<DBIx::Fast::Profile::mysql>, L<DBIx::Fast::Profile::Pg>,
L<DBIx::Fast::Profile::SQLite>, etc.) for native
database diagnostics.
Lazy-loaded on first access.

=head2 tracker

    my $tracker = $db->tracker;

Returns the L<DBIx::Fast::Profiler> instance for query tracking. Once
activated, all queries executed through C<all>, C<hash>, C<val>, C<flat>,
C<array>, C<exec>, C<insert>, C<update>, C<up>, and C<delete> are recorded
with timing information.

    # Activate tracking
    $db->tracker;

    # Run some queries
    $db->all('SELECT * FROM users');
    $db->insert('logs', { action => 'login' });
    $db->up('users', { last_login => $db->now }, { id => 1 });

    # Get statistics
    my $stats = $db->tracker->get_stats;
    printf "Queries: %d, Total: %.4fs, Avg: %.4fs\n",
        $stats->{total_queries}, $stats->{total_time}, $stats->{avg_time};

    # Slow queries
    my $slow = $db->tracker->get_slow_queries(5);
    for my $q (@$slow) {
        printf "%.4fs - %s\n", $q->{duration}, $q->{sql};
    }

    # Stats by type (SELECT, INSERT, UPDATE, DELETE)
    my $by_type = $db->tracker->get_detailed_stats;

    # Print formatted report
    $db->tracker->print_stats;

    # Or route every report as structured data instead of text:
    $db->tracker->output('json');          # one JSON line per event
    $db->tracker->output(sub ($event, $data, $meta) { $log->info(...) });

    # Clear recorded queries
    $db->tracker->clear;

=head2 cached

    # Enable caching at construction:
    my $db = DBIx::Fast->new( SQLite => 'app.db', cache => { default_ttl => 60 } );

    # Read through the cache; tag it with the table so writes can invalidate it:
    my $row  = $db->cached(ttl => 60, tag => 'products')
                  ->hash('SELECT * FROM products WHERE id = ?', $id);
    my $rows = $db->cached(tag => 'products')->all('SELECT * FROM products');

Returns a L<DBIx::Fast::Cached> proxy that wraps the query methods
(C<hash>/C<val>/C<all>/C<array>/C<flat>) with a cache lookup keyed on
(method, SQL, bind values). On a miss the query runs and the result is stored;
on a hit the stored value is returned (including cached "no row" results).

Options: C<ttl> (seconds; falls back to the cache's C<default_ttl>) and
C<tag>/C<tags> (labels for invalidation). Writes through C<insert>/C<update>/
C<up>/C<delete>/C<upsert> automatically invalidate cached reads tagged with the
affected table name. Reads that are untagged, span multiple tables, or are
mutated via raw C<exec>/C<query>/C<execute> are B<not> auto-invalidated - tag
them and/or call L</invalidate> yourself.

If the instance was constructed without C<cache>, the proxy transparently
passes through with no caching and no overhead.

=head2 cache

Returns the underlying L<DBIx::Fast::Cache> instance (or C<undef> if caching is
disabled) for direct C<get>/C<set>/C<invalidate>/C<stats> access.

=head2 invalidate

    $db->invalidate('products');            # after a raw-SQL write
    $db->invalidate(@tables);

Drops every cached entry tagged with the given tag(s). Use it after mutations
that bypass the CRUD auto-invalidation (raw C<exec>/C<query>/C<execute>, or
reads spanning several tables). No-op when caching is disabled.

=head2 load_extension

    my $ext = $db->load_extension('Schema');

Dynamically loads and caches a C<DBIx::Fast::*> extension module.

=head1 UTILITY METHODS

=head2 now

Returns the current B<local> timestamp as C<YYYY-MM-DD HH:MM:SS>.

=head2 now_utc

Like L</now> but in UTC (C<gmtime>). Use it when the database session timezone
differs from the host's, so stored timestamps stay comparable.

=head2 set_error

    $db->set_error($code, $message);

Appends an error to the C<errors> array and updates C<last_error>.


=head2 TableName

    my $table = $db->TableName('users');

Validates a table name. When C<tn =E<gt> 1> is set, checks that the table
exists in the schema cache.

=head2 Exception

    $db->Exception("Something went wrong");

Throws an exception via C<croak>, B<unconditionally> - validation and usage
errors are never silenced by C<RaiseError>/C<PrintError> settings (a
swallowed validation error once let rejected identifiers flow on and become
an injection vector). The message honors C<errors_redact>, and the current
C<last_error> is appended only when it was recorded within the last few
seconds (likely causal context).


=head1 SECURITY

DBIx::Fast uses multiple layers of defense against SQL injection:

=over 4

=item * B<Bind parameters> - All query methods (C<all>, C<hash>, C<val>,
C<flat>, C<array>, C<exec>, C<query>) pass values through DBI
placeholders (C<?>). Values are never interpolated into SQL strings.

=item * B<SQL::Abstract> - CRUD operations (C<insert>, C<update>,
C<delete>) delegate SQL generation to L<SQL::Abstract>, which produces
parameterized queries.

=item * B<Identifier validation> - Table and column names are validated
by C<_safe_id> against the pattern C</^[a-zA-Z_][a-zA-Z0-9_.]*$/>,
rejecting any special characters, spaces, or SQL syntax.

=item * B<Identifier quoting> - After validation, identifiers are quoted
via DBI's C<quote_identifier()> (C<_quote_id>) as defense-in-depth.

=item * B<Operator whitelist> - The C<count> WHERE builder only accepts
operators from a fixed whitelist: C<=>, C<!=>, C<< <> >>, C<< < >>,
C<< <= >>, C<< > >>, C<< >= >>, C<LIKE>, C<NOT>, C<BETWEEN>, C<IS>.
The SQL::Abstract-backed CRUD methods (C<insert>, C<update>, C<delete>,
C<upsert>) enforce their own whitelist on every nested operator key of a
WHERE/SET spec (C<_safe_op_tree>), because SQL::Abstract interpolates
those keys verbatim into the statement. The whitelist is enforced for
B<every> WHERE shape SQL::Abstract accepts - hashref, arrayref
(OR-of-conditions) and nested combinations; a scalarref (literal SQL)
WHERE is rejected outright from these methods.

=item * B<Named parameter substitution> - C<make_sen> converts C<:name>
placeholders to C<?> with a bind array, never interpolating values.

=back

B<Caveat - literal SQL values:> following SQL::Abstract semantics, a
scalar-B<ref> I<value> in a CRUD spec (C<< { col => \"NOW()" } >>) is
inserted as literal SQL, not bound. Only ever pass references you wrote
yourself; data coming from a request must always be a plain scalar.

B<Caveat - profiler log:> the C<profile> option writes a DBI profile log
(F<dbix-fast-PID.log>, containing SQL text) to the current working
directory with default permissions. Do not enable it when the CWD is a
shared or world-writable directory.

=head2 Internal security methods

=head3 _safe_id

    $db->_safe_id($identifier);

Validates that C<$identifier> matches C</^[a-zA-Z_][a-zA-Z0-9_.]*$/>.
Throws an exception on failure. Used for table names, column names, and
any identifier that cannot be passed as a bind parameter.

=head3 _quote_id

    my $quoted = $db->_quote_id($identifier);

Validates via C<_safe_id> then quotes with C<< $dbh->quote_identifier() >>.
Returns driver-appropriate quoted identifier (backticks for MySQL/MariaDB,
double quotes for SQLite/Pg).

=head1 REPORTING SECURITY ISSUES

If you believe you have found a security vulnerability in DBIx::Fast,
please B<do not> file a public GitHub issue.

Report it privately via GitHub's private vulnerability reporting:
L<https://github.com/SeHarrys/dbix-fast/security/advisories/new>

See F<SECURITY.md> in the distribution root for the full security policy.

=head1 SEE ALSO

Subsystems: L<DBIx::Fast::Connector>, L<DBIx::Fast::Transaction>,
L<DBIx::Fast::Result>, L<DBIx::Fast::Schema>, L<DBIx::Fast::Cache>,
L<DBIx::Fast::Cached>, L<DBIx::Fast::Profiler>, L<DBIx::Fast::Profile::MariaDB>,
L<DBIx::Fast::Profile::Pg>.

Built on: L<DBI>, L<SQL::Abstract>, L<Object::Pad>.

Other database layers on CPAN, depending on what you need: full ORMs
(L<DBIx::Class>, L<Rose::DB::Object>, L<Teng>), thinner query helpers
(L<DBIx::Simple>), framework-integrated layers (L<Mojo::Pg>, L<Mojo::mysql>),
and standalone connection management (L<DBIx::Connector>).

=head1 AUTHOR

SeHarrys

=head1 LICENSE AND COPYRIGHT

This is free software under the Artistic License 2.0.
L<http://www.perlfoundation.org/artistic_license_2_0>

=cut
