package DBIx::Fast::SQL;

use v5.38;
use Object::Pad 0.807;
use DBI ();
use DBIx::Fast::Result;
use Time::HiRes ();

# Query and CRUD layer of DBIx::Fast, composed into the main class as a role.
# Object::Pad classes cannot span files, so the role owns the statement state
# ($sql/$p/$results/$last_id/$last_sql) and reaches the rest of the object
# (db/Q/dbd/schema/Exception/set_error/...) through the composed interface.

role DBIx::Fast::SQL {

  # Statement state. Same attributes these fields had on the class, so the
  # public API (sql/p accessors, read-only results/last_id/last_sql) and the
  # encapsulation guarantees are unchanged.
  field $sql      :accessor = undef;
  field $last_sql :reader   = undef;
  field $p        :accessor = undef;
  field $results  :reader   = undef;
  field $last_id  :reader   = undef;

  method _track ($code) {
    if (my $tracker = $self->_active_tracker) {
      my $t0  = Time::HiRes::time();
      my $res = $code->();
      $tracker->add_query($sql, $p // [], $t0, Time::HiRes::time());
      $last_sql = $sql;
      return $res;
    }
    $last_sql = $sql;

    return $code->();
  }

  method q ($stmt, @params) {
    $sql = $stmt;
    $p   = \@params;
  }

  method all (@args) {
    $sql = shift @args;
    $p   = \@args;

    # 3-arg prepare_cached throughout the read methods: allocate a fresh handle
    # when the cached one is still active, so a live query() iterator on the
    # same SQL is not finished out from under the caller.
    $results = $self->_track(sub {
			       my $sth = $self->db->dbh->prepare_cached($sql, undef, 3);
			       $sth->execute(@{$p});
			       my $rows = $sth->fetchall_arrayref({});
			       $sth->finish;
			       return $rows;
			     });
    return $results;
  }

  method flat (@args) {
    $sql = shift @args;
    $p   = \@args;

    my @Flat;
    $self->_track(sub {
		    my $sth = $self->db->dbh->prepare_cached($sql, undef, 3);
		    $sth->execute(@{$p});
		    # fetchall_arrayref([0]) loops in DBI's C path - much
			    # faster than a per-row Perl fetch on large result sets.
			    my $col = $sth->fetchall_arrayref([0]);
			    $sth->finish;
			    @Flat = map { $_->[0] } @$col;
			    1;
		  });

    $results = \@Flat;
    return @Flat;
  }

  method hash (@args) {
    $sql = shift @args;
    $p   = \@args;

    $results = $self->_track(sub {
			       my $sth = $self->db->dbh->prepare_cached($sql, undef, 3);
			       $sth->execute(@{$p});
			       my $row = $sth->fetchrow_hashref;
			       $sth->finish;
			       return $row;
			     });
    return $results;
  }

  method val (@args) {
    $sql = shift @args;
    $p   = \@args;

    $results = $self->_track(sub {
			       my $sth = $self->db->dbh->prepare_cached($sql, undef, 3);
			       $sth->execute(@{$p});
			       my @row = $sth->fetchrow_array;
			       $sth->finish;
			       # First column of first row — historical val() contract.
			       # (DBI leaves multi-column scalar selectrow_array undefined;
			       # we pin FIRST so val() never changes value between builds.)
			       return $row[0];
			     });
    return $results;
  }

  method array (@args) {
    $sql = shift @args;
    $p   = \@args;

    my $ref = $self->_track(sub {
			      my $sth = $self->db->dbh->prepare_cached($sql, undef, 3);
			      $sth->execute(@{$p});
			      my $col = $sth->fetchall_arrayref([0]);
			      $sth->finish;
			      return [ map { $_->[0] } @$col ];
			    });

    $results = $ref // [];
    return $results;
  }

  method query ($stmt, @params) {
    $sql = $stmt;
    $p   = \@params;

    my $sth = $self->_track(sub {
			      # 3-arg prepare_cached: allocate a fresh handle when the
			      # cached one is still active (nested iteration).
			      my $s = $self->db->dbh->prepare_cached($sql, undef, 3);
			      $s->execute(@{$p});
			      return $s;
			    });

    return DBIx::Fast::Result->new(sth => $sth);
  }

  method count ($table_name, $skeel = undef) {
    my $table = $self->TableName($table_name);
    my $qtable = $self->_quote_ident_path($table);

    $sql = "SELECT COUNT(*) FROM $qtable";
    $p   = [];

    $self->_make_where($skeel) if $skeel;

    $results = $self->_track(sub {
			       my $sth = $self->db->dbh->prepare_cached($sql, undef, 3);
			       $sth->execute(@{$p});
			       my ($n) = $sth->fetchrow_array;
			       $sth->finish;
			       return $n;
			     });
    return $results;
  }

  method _make_where ($skeel) {
    state %VALID_OPS = map { $_ => 1 }
      qw(= != <> < <= > >= LIKE NOT BETWEEN IS);

    my @params;
    my @parts;

    for my $K (sort keys %{$skeel}) {
      # Validate AND quote the column by segment, so a qualified name like
      # "t.col" becomes "t"."col" (not the bogus single identifier "t.col").
      my $col = $self->_quote_ident_path($K);

      my $val = $skeel->{$K};
      my $op  = '=';

      if (ref $val eq 'HASH') {
	$self->Exception("Multiple operators for column $K")
	  if keys %$val > 1;
	($op) = keys %$val;
	$self->Exception("Invalid operator: $op")
	  unless $VALID_OPS{uc $op};
	$val = $val->{$op};
	$op  = uc $op;
      }

      if ($op eq 'BETWEEN') {
	$self->Exception("BETWEEN requires a two-element arrayref")
	  unless ref $val eq 'ARRAY' && @$val == 2;
	push @parts,  "$col BETWEEN ? AND ?";
	push @params, @$val;
	next;
      }

      if ($op eq 'IS') {
	$self->Exception("IS only accepts undef (IS NULL); "
	    . "use { '!=' => undef } for IS NOT NULL")
	  if defined $val;
	push @parts, "$col IS NULL";
	next;
      }

      # An undef operand collapses to NULL semantics: "col = NULL" / "col != NULL"
      # are never true in SQL, so emit IS [NOT] NULL with no bind value.
      if (!defined $val) {
	if ($op eq '=') {
	  push @parts, "$col IS NULL";
	}
	elsif ($op eq '!=' || $op eq '<>' || $op eq 'NOT') {
	  push @parts, "$col IS NOT NULL";
	}
	else {
	  $self->Exception("Operator '$op' does not accept an undef value");
	}
	next;
      }

      # NOT on its own maps to <> for portability.
      my $sql_op = $op eq 'NOT' ? '<>' : $op;
      push @parts,  "$col $sql_op ?";
      push @params, $val;
    }

    $sql .= ' WHERE ' . join(' AND ', @parts) if @parts;
    $p = \@params;
  }

  method exec ($stmt, @params) {
    $self->Exception("exec() : SQL statement required") unless $stmt;

    $sql = $stmt;
    $p   = \@params;

    my $sth = $self->db->dbh->prepare_cached($stmt, undef, 3);

    $self->_track(sub {
		    @params ? $sth->execute(@params) : $sth->execute();
		    1;
		  });

    if ($DBI::err) {
      $self->set_error($DBI::err, $DBI::errstr);
      return;
    }

    return $sth;
  }

  method execute ($stmt, $extra = undef, $type = 'arrayref') {
    $sql = $stmt;

    # make_sen sets $p from $extra; without $extra there are no binds, so clear
    # any $p left over from a previous query (else it is bound to this
    # statement - a stray-value or arity-mismatch failure).
    if ($extra) { $self->make_sen($extra) }
    else        { $p = undef }

    $results = $self->_track(sub {
			       my $sth = $self->db->dbh->prepare_cached($sql, undef, 3);
			       $p ? $sth->execute(@{$p}) : $sth->execute;
			       if ($type eq 'hash') {
				 my $row = $sth->fetchrow_hashref;
				 $sth->finish;
				 return $row;
			       } else {
				 my $rows = $sth->fetchall_arrayref({});
				 $sth->finish;
				 return $rows;
			       }
			     });
  }


  # CRUD
  method insert ($table_name, $skeel, @extra) {
    my $table = $self->TableName($table_name);

    $skeel = $self->extra_args($skeel, @extra) if @extra;

    $self->_safe_columns($skeel);

    my ($stmt, @bind) = $self->Q->insert($table, $skeel);

    $sql = $stmt;
    $self->execute_prepare(@bind);

    $self->_update_last_id($table);
    $self->_cache_invalidate_table($table);
    return $last_id;
  }

  method _update_last_id ($table) {
    if ($self->dbd eq 'MariaDB') {
      $last_id = $self->db->dbh->{mariadb_insertid};
    } elsif ($self->dbd eq 'mysql') {
      $last_id = $self->db->dbh->{mysql_insertid};
    } elsif ($self->dbd eq 'SQLite') {
      $last_id = $self->db->dbh->sqlite_last_insert_rowid();
    } elsif ($self->dbd eq 'Pg') {
      # DBD::Pg guesses the serial sequence from the table name. That fails for
      # non-default schemas, renamed/custom sequences, or non-serial PKs. Guard
      # it so a failed lookup yields undef last_id instead of dying AFTER the row
      # was already inserted. NOTE: Pg last_id requires a serial/identity PK; for
      # other cases use INSERT ... RETURNING via exec()/query().
      $last_id = eval { $self->db->dbh->last_insert_id(undef, undef, $table, undef) };
    }
  }

  method update ($table_name, $skeel, @extra) {
    my $table = $self->TableName($table_name);

    $skeel->{sen} = $self->extra_args($skeel->{sen}, @extra)
      if @extra;

    $self->_safe_columns($skeel->{sen});
    $self->_safe_columns($skeel->{where});

    my ($stmt, @bind) =
      $self->Q->update($table, $skeel->{sen}, $skeel->{where});

    $sql = $stmt;
    my $sth = $self->execute_prepare(@bind);
    $self->_cache_invalidate_table($table);
    return $sth;
  }

  method up ($table_name, $data, $where, $time_col = undef) {
    if ($time_col) {
      $self->update($table_name,
		    { sen => $data, where => $where }, time => $time_col);
    } else {
      $self->update($table_name,
		    { sen => $data, where => $where });
    }
  }

  method delete ($table_name, $skeel) {
    my $table = $self->TableName($table_name);

    $self->_safe_columns($skeel);

    my ($stmt, @bind) = $self->Q->delete($table, $skeel);

    $sql = $stmt;
    my $sth = $self->execute_prepare(@bind);
    $self->_cache_invalidate_table($table);
    return $sth;
  }

  method upsert ($table_name, $row, $conflict_keys, $update_cols = undef) {
    $self->Exception("upsert: row hashref required")
      unless ref $row eq 'HASH' && %$row;
    $self->Exception("upsert: conflict_keys arrayref required")
      unless ref $conflict_keys eq 'ARRAY' && @$conflict_keys;

    my $table = $self->TableName($table_name);
    $self->_safe_columns($row);
    $self->_safe_id($_) for @$conflict_keys;

    unless ($update_cols) {
      my %ck = map { $_ => 1 } @$conflict_keys;
      $update_cols = [ grep { !$ck{$_} } sort keys %$row ];
    }
    $self->Exception(
      "upsert: nothing to update on conflict "
      . "(all row columns are conflict keys)")
      unless @$update_cols;
    $self->_safe_id($_) for @$update_cols;

    my @cols = sort keys %$row;
    my $dbh  = $self->db->dbh;
    my $qt   = $self->_quote_ident_path($table);
    my $qcols = join(', ', map { $dbh->quote_identifier($_) } @cols);
    my $placeholders = join(', ', ('?') x scalar(@cols));
    my $clause = $self->_upsert_clause($conflict_keys, $update_cols);

    $sql = "INSERT INTO $qt ($qcols) VALUES ($placeholders) $clause";
    my @bind = map { $row->{$_} } @cols;
    my $sth = $self->execute_prepare(@bind);
    $self->_cache_invalidate_table($table);
    return $sth;
  }

  method insert_many ($table_name, $rows) {
    $self->Exception("insert_many: at least one row required")
      unless ref $rows eq 'ARRAY' && @$rows;
    $self->Exception("insert_many: rows must be hashrefs")
      if grep { ref $_ ne 'HASH' } @$rows;

    my $table = $self->TableName($table_name);
    my @cols  = sort keys %{ $rows->[0] };
    $self->_safe_columns($rows->[0]);

    my $signature = join(',', @cols);
    for my $i (1 .. $#$rows) {
      my $sig = join(',', sort keys %{ $rows->[$i] });
      $self->Exception(
        "insert_many: row $i has different columns than row 0")
        if $sig ne $signature;
    }

    my $dbh   = $self->db->dbh;
    my $qt    = $self->_quote_ident_path($table);
    my $qcols = join(', ', map { $dbh->quote_identifier($_) } @cols);
    my $row_ph = '(' . join(', ', ('?') x scalar(@cols)) . ')';
    my $values = join(', ', ($row_ph) x scalar(@$rows));

    $sql = "INSERT INTO $qt ($qcols) VALUES $values";
    my @bind = map { my $r = $_; map { $r->{$_} } @cols } @$rows;
    $self->execute_prepare(@bind);

    $self->_update_last_id($table);
    $self->_cache_invalidate_table($table);
    return scalar @$rows;
  }

  method upsert_many ($table_name, $rows, $conflict_keys, $update_cols = undef) {
    $self->Exception("upsert_many: at least one row required")
      unless ref $rows eq 'ARRAY' && @$rows;
    $self->Exception("upsert_many: rows must be hashrefs")
      if grep { ref $_ ne 'HASH' } @$rows;
    $self->Exception("upsert_many: conflict_keys arrayref required")
      unless ref $conflict_keys eq 'ARRAY' && @$conflict_keys;

    my $table = $self->TableName($table_name);
    my @cols  = sort keys %{ $rows->[0] };
    $self->_safe_columns($rows->[0]);
    $self->_safe_id($_) for @$conflict_keys;

    unless ($update_cols) {
      my %ck = map { $_ => 1 } @$conflict_keys;
      $update_cols = [ grep { !$ck{$_} } @cols ];
    }
    $self->Exception(
      "upsert_many: nothing to update on conflict "
      . "(all row columns are conflict keys)")
      unless @$update_cols;
    $self->_safe_id($_) for @$update_cols;

    my $signature = join(',', @cols);
    for my $i (1 .. $#$rows) {
      my $sig = join(',', sort keys %{ $rows->[$i] });
      $self->Exception(
        "upsert_many: row $i has different columns than row 0")
        if $sig ne $signature;
    }

    my $dbh    = $self->db->dbh;
    my $qt     = $self->_quote_ident_path($table);
    my $qcols  = join(', ', map { $dbh->quote_identifier($_) } @cols);
    my $row_ph = '(' . join(', ', ('?') x scalar(@cols)) . ')';
    my $values = join(', ', ($row_ph) x scalar(@$rows));
    my $clause = $self->_upsert_clause($conflict_keys, $update_cols);

    $sql = "INSERT INTO $qt ($qcols) VALUES $values $clause";
    my @bind = map { my $r = $_; map { $r->{$_} } @cols } @$rows;
    $self->execute_prepare(@bind);
    $self->_cache_invalidate_table($table);

    return scalar @$rows;
  }


  # Builds the ON CONFLICT / ON DUPLICATE KEY UPDATE clause.
  # Pg/SQLite use ON CONFLICT (cols) DO UPDATE SET c = excluded.c.
  # MariaDB/mysql use ON DUPLICATE KEY UPDATE c = VALUES(c). The conflict_keys
  # are validated for symmetry but not emitted in MariaDB syntax (any UNIQUE
  # or PRIMARY KEY triggers the update there).
  method _upsert_clause ($conflict_keys, $update_cols) {
    my $dbh = $self->db->dbh;

    if ($self->dbd eq 'mysql') {
      # MySQL 8.0.20+ deprecates VALUES(col); the row-alias form replaces it but
      # requires MySQL >= 8.0.19 (older servers would throw a syntax error). Gate
      # on the server version and fall back to VALUES() on older MySQL. The alias
      # is part of the returned clause so it lands right after the VALUES list at
      # the call site: "INSERT ... VALUES (...) AS new ON DUPLICATE KEY ...".
      my $ver = $dbh->{mysql_serverversion} // 0;
      if ($ver >= 80019) {
        my @sets = map {
          my $qc = $dbh->quote_identifier($_);
          "$qc = new.$qc";
        } @$update_cols;
        return "AS new ON DUPLICATE KEY UPDATE " . join(', ', @sets);
      }
      my @sets = map {
        my $qc = $dbh->quote_identifier($_);
        "$qc = VALUES($qc)";
      } @$update_cols;
      return "ON DUPLICATE KEY UPDATE " . join(', ', @sets);
    }

    if ($self->dbd eq 'MariaDB') {
      # MariaDB still supports VALUES(col) (it lacks the row-alias syntax).
      my @sets = map {
        my $qc = $dbh->quote_identifier($_);
        "$qc = VALUES($qc)";
      } @$update_cols;
      return "ON DUPLICATE KEY UPDATE " . join(', ', @sets);
    }

    my $qck = join(', ',
                   map { $dbh->quote_identifier($_) } @$conflict_keys);
    my @sets = map {
      my $qc = $dbh->quote_identifier($_);
      "$qc = excluded.$qc";
    } @$update_cols;
    return "ON CONFLICT ($qck) DO UPDATE SET " . join(', ', @sets);
  }

  method extra_args ($skeel, %args) {
    if ($args{time}) {
      $self->_safe_id($args{time});
      $skeel->{ $args{time} } = $self->now();
    }
    return $skeel;
  }

  method make_sen ($skeel) {
    my $stmt = $sql // '';
    my @params;

    # Single left-to-right pass: a quoted string literal is matched as a unit
    # and left untouched, so ':' inside it is never substituted. A placeholder
    # is matched only outside literals; the (?<![:\w]) lookbehind keeps the
    # second colon of a Postgres '::cast' (and back-to-back names) from matching.
    $stmt =~ s{
        ( '[^']*' | "[^"]*" )         # $1: quoted literal -> leave as-is
      |
        (?<![:\w]) : ([A-Za-z_]\w*)   # $2: named placeholder -> bind
    }{
        if (defined $1) {
            $1;
        }
        else {
            push @params, (exists $skeel->{$2} ? $skeel->{$2} : undef);
            '?';
        }
    }gex;

    $sql = $stmt;
    $p   = \@params;
  }

  method execute_prepare (@params) {
    my $stmt = $sql // '';

    $self->Exception("execute_prepare(): SQL not set")
      unless length $stmt;

    my $dbh = $self->db && $self->db->dbh
      or $self->Exception("execute_prepare(): No DB handle");

    # 3-arg form: if an identical cached handle is still active, get a fresh one
    # instead of emitting a "still Active" warning (execute_prepare returns an
    # unfinished handle to the caller).
    my $sth = $dbh->prepare_cached($stmt, undef, 3)
      or $self->Exception(
			  "prepare() failed: " . ($dbh->errstr // 'unknown'));

    $p = \@params;

    $self->_track(sub {
		    $sth->execute(@params)
		      or $self->Exception(
					  "execute() failed: " . ($sth->errstr // 'unknown'));
		    1;
		  });

    $last_sql = $stmt;

    return $sth;
  }

}

1;

__END__

=pod

=head1 NAME

DBIx::Fast::SQL - Query and CRUD methods of DBIx::Fast

=head1 SYNOPSIS

    # These methods are composed into DBIx::Fast itself:
    my $rows = $db->all('SELECT * FROM users WHERE status = ?', 1);
    my $id   = $db->insert('users', { name => 'Alice' });

=head1 DESCRIPTION

Object::Pad role composed into L<DBIx::Fast>. It owns the statement state
(C<sql>, C<p>, C<results>, C<last_id>, C<last_sql> - same accessors as
always) and provides every query and CRUD method. You never use this module
directly: all methods below are called on the C<DBIx::Fast> object.

Every method below takes raw SQL; values are passed as C<?> placeholders
(or C<:name> for L</execute>) and bound, never interpolated. For a
task-oriented overview see L<DBIx::Fast/RAW SQL>.

=head1 QUERY METHODS

=head2 all

    $db->all('SELECT * FROM users WHERE id > ?', 10);
    my $rows = $db->results;  # arrayref of hashrefs

Executes SQL and stores all rows in C<results>.

=head2 hash

    $db->hash('SELECT * FROM users WHERE id = ?', 1);
    my $row = $db->results;  # hashref

Executes SQL and stores a single row in C<results>.

=head2 val

    my $name = $db->val('SELECT name FROM users WHERE id = ?', 1);

Executes SQL and returns a single scalar value: the B<first> column of the
first row. Intended for single-column SELECTs; with multiple columns the
extra ones are ignored.

=head2 flat

    my @names = $db->flat('SELECT name FROM users');

Executes SQL and returns a flat list of values.

=head2 array

    $db->array('SELECT name FROM users');
    my $names = $db->results;  # arrayref

Executes SQL and stores a single column as arrayref in C<results>.

=head2 query

    my $rs = $db->query('SELECT id, name FROM users WHERE active = ?', 1);
    while (my $row = $rs->hash) {
        say "$row->{id}: $row->{name}";
    }

Executes SQL with bind parameters and returns a L<DBIx::Fast::Result>
iterator. Unlike C<all>, this does not load the entire result set into
memory - rows are fetched one at a time via C<hash>, C<array>, C<all>,
or C<flat> on the result object. The statement handle is automatically
finished when the result object goes out of scope.

=head2 count

    my $total = $db->count('users');
    my $active = $db->count('users', { status => 1 });

Returns row count, optionally filtered by WHERE conditions.

=head2 exec

    $db->exec('CREATE TABLE foo (id INT)');
    $db->exec('INSERT INTO foo VALUES (?)', 42);
    $db->exec('UPDATE foo SET id = ? WHERE id = ?', 99, 42);
    my $sth = $db->exec('SELECT * FROM foo WHERE id > ?', 0);

The general-purpose raw-SQL method: runs any statement (DDL, DML or
vendor-specific SQL) with optional C<?> bind parameters and returns the
statement handle. Records the query in the profiler when one is active.
For a SELECT you usually want C<all>/C<hash>/C<val>/C<query> instead, which
fetch the rows for you.

=head2 execute

    $db->execute('SELECT * FROM users WHERE name = :name', { name => 'Alice' });
    $db->execute('SELECT * FROM users WHERE id = :id', { id => 1 }, 'hash');

Executes SQL with named parameter substitution (C<:name> syntax). Third
argument selects result type: C<arrayref> (default) or C<hash>.

=head1 CRUD METHODS

=head2 insert

    $db->insert('users', { name => 'Alice', status => 1 });
    $db->insert('users', { name => 'Alice' }, time => 'created_at');
    my $id = $db->last_id;

Inserts a row using L<SQL::Abstract>. Sets C<last_id> automatically based
on the database driver.

=head2 update

    $db->update('users', {
        sen   => { name => 'Bob', status => 1 },
        where => { id => 1 },
    });
    $db->update('users', {
        sen   => { name => 'Bob' },
        where => { id => 1 },
    }, time => 'updated_at');

Updates rows using L<SQL::Abstract>. Pass C<time =E<gt> 'column_name'> to
auto-set a timestamp column to C<now()>.

=head2 up

    $db->up('users', { name => 'Bob' }, { id => 1 });
    $db->up('users', { name => 'Bob' }, { id => 1 }, 'updated_at');

Shortcut for C<update>. Arguments: table, data hashref, where hashref,
optional time column name (positional).

=head2 delete

    $db->delete('users', { id => 1 });

Deletes rows using L<SQL::Abstract>.

=head2 upsert

    $db->upsert('products',
        { sku => 'X', name => 'Widget', stock => 5, price => 9.99 },
        ['sku'],                  # conflict keys (UNIQUE / PRIMARY KEY)
        [qw(stock price)],        # optional: columns to update on conflict
    );

Inserts the row, or updates the conflict-targeted columns if a row with
matching conflict keys already exists. Translates to driver-native syntax
(C<ON CONFLICT (...) DO UPDATE> for PostgreSQL and SQLite 3.24+,
C<ON DUPLICATE KEY UPDATE> for MariaDB and MySQL).

If C<update_cols> is omitted, every row column that is not in
C<conflict_keys> is updated. If after that the update set is empty, an
exception is raised - use a regular C<insert> if you do not want to update
anything on conflict.

C<last_id> is B<not> updated by C<upsert> because the operation may have
been an insert or an update.

=head2 insert_many

    $db->insert_many('orders', [
        { customer_id => 1, total => 99 },
        { customer_id => 2, total => 45 },
    ]);

Inserts multiple rows in a single statement. All rows must share the same
column set (same keys); rows whose keys differ from the first one raise an
exception. Returns the number of rows inserted. C<last_id> is set to the
first inserted row id (driver convention).

For very large arrays you should chunk the input manually to stay within
the driver's C<max_allowed_packet> / statement-size limits.

=head2 upsert_many

    $db->upsert_many('products', \@rows, ['sku'], [qw(stock price)]);

Bulk INSERT with per-row conflict resolution, combining C<insert_many> and
C<upsert>. Same column-uniformity rule as C<insert_many>. Returns the number
of rows in the batch.

=head2 make_sen

    $db->sql('SELECT * FROM users WHERE name = :name AND age = :age');
    $db->make_sen({ name => 'Alice', age => 30 });
    # $db->sql is now 'SELECT * FROM users WHERE name = ? AND age = ?'
    # $db->p is ['Alice', 30]

Replaces named placeholders (C<:name>) with C<?> in order of appearance
in the SQL string. Supports duplicate placeholders. Quoted string literals
(C<'...'> / C<"...">) and PostgreSQL C<::cast> operators are left untouched.

Named parameters are intended for simple SQL: the literal-skipping is based on
straight single/double quotes and does B<not> understand backslash escapes
(C<'a\'b'>), PostgreSQL dollar-quoting (C<$$...$$>), or SQL comments. For SQL
using those constructs, use positional C<?> placeholders instead.

=head2 q

    $db->q('SELECT * FROM users WHERE id = ?', 1);

Sets C<sql> and C<p> (bind parameters) for subsequent use.

=head2 execute_prepare

    $db->sql('INSERT INTO users (name) VALUES (?)');
    $db->execute_prepare('Alice');

Prepares and executes the current C<sql> with bind parameters.

=head1 SEE ALSO

L<DBIx::Fast> - constructor, accessors, subsystems and the SECURITY notes
that apply to these methods.

=head1 AUTHOR

SeHarrys

=head1 LICENSE

This is free software under the Artistic License 2.0.

=cut
