package DBIx::Fast::Cached;

use v5.38;
use Object::Pad;

# Thin proxy returned by $db->cached(...) that wraps query methods with
# a cache lookup. Cache key is derived from (sql, bind params).
#
# Usage:
#   my $row  = $db->cached(ttl => 60, tag => 'products')
#                 ->hash('SELECT * FROM products WHERE id = ?', $id);
#   my $rows = $db->cached(ttl => 30)->all('SELECT * FROM categories');

class DBIx::Fast::Cached {
  field $dbix :param :reader;
  field $ttl  :param        = undef;
  field $tag  :param        = undef;   # convenience: single tag → tags
  field $tags :param        = undef;

  field $_ns;

  ADJUST {
    # Normalize: tag => 'x' and tags => ['x'] are interchangeable.
    if (defined $tag && !defined $tags) {
      $tags = [$tag];
    }

    # Connection identity is part of the key: two instances sharing one
    # external backend (CHI/Redis) but pointing at different databases or
    # connecting as different DB users must never serve each other's rows.
    my $user = eval { $dbix->args->{Auth}{user} } // '';
    $_ns = ($dbix->dsn // '') . "\0" . $user . "\0";
  }

  method _execute ($method, $sql, @params) {
    my $cache = $dbix->cache
      or return $dbix->$method($sql, @params);  # cache disabled, passthrough

    # The method is part of the key: the same SQL+params returns different shapes
    # via all()/hash()/val()/array(), so they must not share a cache entry.
    my $key = $cache->build_key("$_ns$method\0$sql", \@params);
    my $hit = $cache->get($key);
    # Values are wrapped as [$val] so a cached undef (a negative/no-row result)
    # is still a hit and is not re-queried on every call.
    return $hit->[0] if $hit;

    my $val = $dbix->$method($sql, @params);
    $cache->set($key, [$val],
                defined $ttl ? (ttl => $ttl) : (),
                $tags         ? (tags => $tags) : ());
    return $val;
  }

  method hash  ($sql, @p) { $self->_execute('hash',  $sql, @p) }
  method val   ($sql, @p) { $self->_execute('val',   $sql, @p) }
  method all   ($sql, @p) { $self->_execute('all',   $sql, @p) }
  method array ($sql, @p) { $self->_execute('array', $sql, @p) }

  # flat returns a LIST; cache it as arrayref and re-flatten on hit.
  method flat ($sql, @p) {
    my $cache = $dbix->cache
      or return $dbix->flat($sql, @p);

    my $key = $cache->build_key("${_ns}flat\0$sql", \@p);
    if (my $hit = $cache->get($key)) {
      return @$hit;
    }

    # \@rows is a fresh per-call lexical, so storing the reference directly
    # is safe and avoids copying the whole result set.
    my @rows = $dbix->flat($sql, @p);
    $cache->set($key, \@rows,
                defined $ttl ? (ttl => $ttl) : (),
                $tags         ? (tags => $tags) : ());
    return @rows;
  }
}

1;

__END__

=head1 NAME

DBIx::Fast::Cached - Query proxy that caches results

=head1 SYNOPSIS

    my $row  = $db->cached(ttl => 60, tag => 'products')
                  ->hash('SELECT * FROM products WHERE id = ?', $id);

    my $rows = $db->cached(ttl => 30, tags => ['categories', 'navigation'])
                  ->all('SELECT * FROM categories WHERE active = 1');

    $db->cache->invalidate('products');   # drop cached product queries

=head1 DESCRIPTION

Returned by C<< $db->cached(...) >>. Wraps the standard query methods
(C<hash>, C<val>, C<all>, C<array>, C<flat>) with a cache lookup keyed on
(method, sql, bind params). Cache miss executes the underlying query and
stores the result; cache hit returns the stored value.

If the parent C<DBIx::Fast> instance was not constructed with C<cache>,
calls pass through to the original methods (no overhead, no caching).

B<Caveat:> a cache hit returns the B<same reference> that is stored in the
cache (results are not deep-copied, to keep the cache fast). Do not mutate a
returned hashref/arrayref in place - it would corrupt the cached entry for the
next reader. Copy it first if you need to modify it.

=head1 METHODS

=head2 hash / val / all / array

    my $row  = $db->cached(ttl => 60)->hash($sql, @binds);
    my $v    = $db->cached->val($sql, @binds);
    my $rows = $db->cached->all($sql, @binds);
    my $col  = $db->cached->array($sql, @binds);

Same signature and return value as the L<DBIx::Fast> method of the same
name, with a cache lookup in front. A cached C<undef> (no-row result) is
still a hit and is not re-queried.

=head2 flat

    my @names = $db->cached->flat($sql, @binds);

List-returning variant: the result is cached as an arrayref and
re-flattened on every hit.

=head1 OPTIONS

=over 4

=item C<ttl>

Time-to-live in seconds. Falls back to cache's C<default_ttl> when omitted.

=item C<tag>

Single tag attached to the cached entry. Convenience alias for
C<< tags => ['x'] >>.

=item C<tags>

Arrayref of tags. Use C<< $db->cache->invalidate($tag) >> to drop every
entry tagged with C<$tag>.

=back

=head1 AUTHOR

SeHarrys

=head1 LICENSE

This is free software under the Artistic License 2.0.

=cut
