AmberDB
view release on metacpan or search on metacpan
lib/AmberDB/Base/Facet.pm view on Meta::CPAN
}
my %all_counts;
return \%all_counts unless -e $fac_path;
if (@scan_ids) {
for my $blk (@$blks) {
my @keys = map { "$blk:$_" } @scan_ids;
my $res = $self->recs_get( $fac_path, @keys );
for my $id (@scan_ids) {
my $raw = $res ? $res->{"$blk:$id"} : undef;
next unless defined $raw && $raw ne '';
my @vals = ( index( $raw, "\t" ) == -1 ) ? ($raw) : split /\t/, $raw;
for my $v (@vals) {
$all_counts{$blk}{$v}++;
}
}
}
}
else {
my %wanted_blks = map { ( ref($_) eq 'HASH' ? $_->{blk} : $_ ) => 1 } @$blks;
$self->recs_scan(
$fac_path,
sub {
my ( $k, $raw ) = @_;
return unless defined $raw && $raw ne '';
return unless $k =~ /^(\d+):(\d+)$/;
my ( $k_blk, $rid ) = ( $1, $2 );
return unless $wanted_blks{$k_blk};
my @vals = ( index( $raw, "\t" ) == -1 ) ? ($raw) : split /\t/, $raw;
for my $v (@vals) {
$all_counts{$k_blk}{$v}++;
}
}
);
}
if ( -e $unq_path && %all_counts ) {
for my $blk ( keys %all_counts ) {
my $cnt_map = $all_counts{$blk};
next unless $cnt_map && ref($cnt_map) eq 'HASH' && %$cnt_map;
my @val_ids = keys %$cnt_map;
my @n_keys = map { "$blk:n:$_" } @val_ids;
my $names = $self->index_get( $unq_path, \@n_keys, 'raw' );
if ( $names && ref($names) eq 'HASH' && %$names ) {
my %named_map;
for my $vid (@val_ids) {
my $name = $names->{"$blk:n:$vid"} // $vid;
$named_map{$name} = $cnt_map->{$vid};
}
$all_counts{$blk} = \%named_map;
}
}
}
return \%all_counts;
}
# Generates schema-driven facet menu structure and performs active filtering.
# my $result = $adb->facet_menu($tableid, [\%options]);
# options: selected => \%selected, facet_defs => \@facet_defs, offset => 0, limit => 20, base_ids => \@base_scope
# Legacy: my $result = $adb->facet_menu($tableid, \%selected, \@facet_defs, \%opts);
# ------------------------------------------------
sub facet_menu {
my ( $self, $tableid, @args ) = @_;
$tableid or return ( wantarray ? () : {} );
my $table_info = $self->table_info($tableid);
return ( wantarray ? () : {} ) unless $table_info && $table_info->{use_facet};
my ( $selected, $facet_defs, $opts );
if ( @args == 1 && ref( $args[0] ) eq 'HASH' ) {
my $arg = $args[0];
if ( exists $arg->{selected}
|| exists $arg->{facet_defs}
|| exists $arg->{offset}
|| exists $arg->{start}
|| exists $arg->{limit}
|| exists $arg->{base_ids}
|| exists $arg->{scope_ids}
|| exists $arg->{blocks}
|| exists $arg->{filter}
|| exists $arg->{where}
|| exists $arg->{match}
|| exists $arg->{range} )
{
$opts = $arg;
$selected = $arg->{selected} || $arg->{filter} || $arg->{where} || $arg->{match} || {};
$facet_defs = $arg->{facet_defs} || $arg->{blocks} || $table_info->{facet_block} || [];
}
else {
$selected = $arg;
$facet_defs = $table_info->{facet_block} || [];
$opts = {};
}
}
else {
( $selected, $facet_defs, $opts ) = @args;
}
$selected ||= {};
$facet_defs ||= $table_info->{facet_block} || [];
$opts ||= {};
my $table_path = $self->table_path($tableid);
my $offset = $opts->{offset} // $opts->{start} // 0;
my $limit = $opts->{limit} // 0;
my $base_scope = $opts->{base_ids} || $opts->{scope_ids} || undef;
if ( $opts && $opts->{range} ) {
if ( my $ranges = $self->normalize_range_opts( $tableid, $opts ) ) {
if ( $base_scope && @$base_scope ) {
my @scoped = $self->filter_ids_by_range( $tableid, $base_scope, $ranges );
$base_scope = \@scoped;
}
else {
my ( undef, @all_active );
my $fac_path = "$table_path.fac";
( undef, @all_active ) = $self->index_get( $fac_path, "active" ) if -e $fac_path;
unless (@all_active) {
my $inx_path = "$table_path.inx";
( undef, @all_active ) = $self->index_get( $inx_path, "keys" ) if -e $inx_path;
}
@all_active = $self->table_keys($tableid) unless @all_active;
my @scoped = $self->filter_ids_by_range( $tableid, \@all_active, $ranges );
$base_scope = \@scoped;
}
}
}
# Normalize active selections into %active_filter
my %active_filter;
for my $raw_k ( keys %$selected ) {
my $blk = $raw_k;
$blk =~ s/^f//; # Strip leading 'f' prefix if passed as f1, f2...
my $v = $selected->{$raw_k};
if ( defined $v && $v ne '' ) {
my @vals = ref($v) eq 'ARRAY' ? @$v : split /,/, $v;
@vals = grep { defined $_ && $_ ne '' } @vals;
$active_filter{$blk} = \@vals if @vals;
}
}
# 1. Active Filtering (Filtered IDs)
my ( $filtered_ids, $total_count ) = ( [], 0 );
if (%active_filter) {
my $f_res = $self->field_filter(
$tableid,
{
type => 'and',
filter => \%active_filter,
offset => $offset,
limit => $limit,
( $opts->{range} ? ( range => $opts->{range} ) : () ),
}
);
$filtered_ids = $f_res->{ids} || [];
if ( $base_scope && @$base_scope ) {
my %scope_map = map { $_ => 1 } @$base_scope;
$filtered_ids = [ grep { $scope_map{$_} } @$filtered_ids ];
}
$total_count = scalar @$filtered_ids;
}
elsif ( $base_scope && @$base_scope ) {
$total_count = scalar @$base_scope;
if ($limit) {
my ( undef, @slice ) = $self->recs_cutting( $offset, $limit, @$base_scope );
$filtered_ids = \@slice;
}
else {
$filtered_ids = $base_scope;
}
}
else {
my $fac_path = "$table_path.fac";
if ( -e $fac_path ) {
( undef, my @all_active ) = $self->index_get( $fac_path, "active" );
unless (@all_active) {
my $inx_path = "$table_path.inx";
( undef, @all_active ) = $self->index_get( $inx_path, "keys" ) if -e $inx_path;
}
$total_count = scalar @all_active;
if ($limit) {
my ( undef, @slice ) = $self->recs_cutting( $offset, $limit, @all_active );
$filtered_ids = \@slice;
}
else {
$filtered_ids = \@all_active;
}
}
}
# 2. Compute Facet Counts (Disjunctive / Multi-pass)
my %all_counts;
if ( !%active_filter ) {
my @blks = map { ref($_) eq 'HASH' ? $_->{blk} : $_ } @$facet_defs;
lib/AmberDB/Base/Facet.pm view on Meta::CPAN
@vals = grep { $self->_cmp_op( $_, $fo_op, $fo_val ) } @vals;
}
# csv_list whitelist if defined
if ( ref($cfg) eq 'HASH' && defined $cfg->{csv_list} && $cfg->{csv_list} ne '' ) {
my %csv_allowed = map { $_ => 1 } split /,/, $cfg->{csv_list};
@vals = grep { $csv_allowed{$_} } @vals;
}
# Sorting
my $sort_mode = ( ref($cfg) eq 'HASH' ? $cfg->{sort} : '' ) || 'count';
if ( $sort_mode eq 'count' ) {
@vals = sort { ( $counts->{$b} || 0 ) <=> ( $counts->{$a} || 0 ) } @vals;
}
elsif ( $sort_mode eq 'value' ) {
@vals = sort { $a cmp $b } @vals;
}
# Top-N Limiting
my $limit_n = ( ref($cfg) eq 'HASH' ? ( $cfg->{limit} // $cfg->{display_limit} ) : 0 ) || 0;
if ( $limit_n && @vals > $limit_n ) {
@vals = @vals[ 0 .. ( $limit_n - 1 ) ];
}
# Batch Label Resolution (RDBM -> .unq bidirectional -> option)
my %name_map;
if ( ref($cfg) eq 'HASH' && $cfg->{table} ) {
if (@vals) {
my @recs = $self->read_list( $cfg->{table}, \@vals );
my $name_idx = $cfg->{name_idx} // 2;
%name_map = map { $_->[0] => $_->[$name_idx] } @recs;
}
}
else {
# .unq sözlük dosyasından $blk:n: prefixi ile çift yönlü çözümle
my $unq_file = "${table_path}.unq";
if ( -e $unq_file && @vals ) {
my @n_keys = map { "$blk:n:$_" } @vals;
my $res = $self->index_get( $unq_file, \@n_keys, 'raw' );
if ( $res && ref($res) eq 'HASH' ) {
for my $val (@vals) {
my $text = $res->{"$blk:n:$val"};
if ( defined $text && $text ne '' ) {
$name_map{$val} = $text;
}
}
}
}
# Åema option alanı fallback'i (örn: "1:SatıÅta,0:SatıŠDıÅı")
my $opt_str = $table_info->{blocks}->[$blk]->{option} // '';
if ($opt_str) {
for my $pair ( split /,/, $opt_str ) {
my ( $v, $l ) = split /:/, $pair, 2;
$name_map{$v} //= $l // $v;
}
}
}
# Active status for this block
my %selected_vals = map { $_ => 1 } @{ $active_filter{$blk} // [] };
my $active_cnt = scalar keys %selected_vals;
$active_counts{$blk} = $active_cnt;
my @items;
for my $val (@vals) {
push @items, {
uid => "fc_${blk}_${val}",
param => "f$blk",
val => $val,
label => ( $name_map{$val} // $val ),
count => ( $counts->{$val} // 0 ),
checked => ( $selected_vals{$val} ? "1" : "" ),
};
}
my $group_data = {
blk => $blk,
name => $label,
active => ( $active_cnt ? "1" : "" ),
active_count => $active_cnt,
records => \@items,
};
push @groups, $group_data;
$groups_by_blk{$blk} = \@items;
}
my $res = {
count => $total_count,
ids => $filtered_ids,
groups => \@groups,
groups_by_blk => \%groups_by_blk,
active_counts => \%active_counts,
counts => \%all_counts,
};
return wantarray ? @groups : $res;
}
=encoding utf8
=head1 NAME
AmberDB::Index::Facet - Column-oriented facet indexing, disjunctive counting, and navigation menu generator
=head1 SYNOPSIS
# Querying from AmberDB instance ($adb inherits AmberDB::Index::Facet):
# 1. Generate full-catalog or filtered facet menu with disjunctive counts:
my $menu_data = $adb->facet_menu(
"catalog_product",
{ 1 => "5", 2 => [ "12", "14" ] }, # %selected_filters
\@facet_block_definitions,
{ sort => 'count', top => 10 } # %options
);
# 2. Dynamic Scoped facet menu (e.g. within search results or category scope):
my $search_facets = $adb->facet_menu(
"catalog_product",
\%selected,
\@facet_defs,
{ base_ids => \@search_result_ids }
);
# 3. Direct facet key counts for a single block:
my $counts = $adb->field_fltkeys("catalog_product", {
target_block => 2,
base_ids => \@active_product_ids,
});
=head1 DESCRIPTION
C<AmberDB::Index::Facet> provides a high-performance, column-oriented forward indexing and disjunctive facet aggregation engine designed for low-latency faceted navigation across large-scale catalogs.
B<Inheritance Note:> C<AmberDB> inherits from C<AmberDB::Index::Facet> via C<use parent>. All facet query and menu methods documented below are invoked directly on C<$adb>.
=head1 KEY ARCHITECTURAL FEATURES
=over 4
=item * B<1. Columnar Unified Storage (C<$table_path.fac>):> Facet data is stored in a unified columnar forward index file (C<$table_path.fac>). Each record's block values are keyed as C<$blk:$rid> mapping to packed value IDs, enabling fast single-co...
=item * B<2. Active-Only Storage Guarantee:> Facet index files store B<only currently active records>. Inactive, discontinued, or out-of-stock records violating C<facet_rules> / C<junk_rules> are excluded during indexing, eliminating the overhead of ...
=item * B<3. Bidirectional String Dictionary (C<.unq>):> Text facets (e.g. colors, specifications) map transparently between string labels and compact numeric dictionary IDs.
=item * B<4. Dynamic Scoping (C<base_ids>):> When computing facet counts within search results or subcategories, passing C<base_ids =E<gt> \@ids> bounds the aggregation strictly to matching records.
=item * B<5. Multi-Select Disjunctive Faceting:> Supports multi-selection where checking multiple items within the same filter group uses OR logic (showing counts of remaining options), while combining across different filter groups uses AND logic.
=back
=head1 METHODS
=head2 facet_menu($tableid, [\%options])
High-level faceted navigation menu generator.
Options:
=over 4
=item * C<selected>: Hash of currently active filter selections: C<{ block_idx =E<gt> $val_or_arr_ref }>. (Aliases: C<filter>, C<where>, C<match>).
=item * C<facet_defs>: Array of facet block definitions (or reads directly from table schema C<facet_block> if omitted).
=item * C<offset>: Pagination start offset (default: 0).
=item * C<limit>: Page size limit (default: 0 = unpaginated).
=item * C<base_ids>: (Alias: C<scope_ids>) Array reference of record IDs to scope calculation (e.g. search result IDs).
=item * C<sort>: C<'count'> (default, descending count) or C<'label'> / C<'name'> (alphabetical).
=item * C<top>: Limit maximum items returned per facet group (e.g. 10).
=item * C<min_count>: Minimum count required to include an item (default: 1).
=item * C<range>: Numerical / chronological range filtering C<{ block => 4, min => 1000, max => 2000 }>.
=back
Returns a comprehensive result hash:
C<{ count => $total, ids => \@filtered_ids, groups => \@groups, active_counts => \%counts, counts => \%all_counts }>.
my $menu = $adb->facet_menu("catalog_product", {
selected => { 1 => "5" },
range => { block => "price", min => 1000, max => 2000 },
offset => 0,
limit => 20,
});
Legacy invocation C<$adb->facet_menu($tableid, \%selected, \@facet_defs, \%options)> remains fully supported.
=head2 field_fltkeys($tableid, \%opts)
Calculates facet key counts for a target block directly from active C<.fac>. Automatically resolves dictionary string labels.
Options:
=over 4
=item * C<target_block>: (Required) Attribute block index to aggregate facet counts for.
=item * C<filter>: (Optional, aliases: C<where>, C<match>) Active filter conditions on other blocks C<{ block_idx => $value }>.
=item * C<base_ids>: (Optional, alias: C<scope_ids>) Array reference of record IDs to scope calculation to.
=item * C<range>: (Optional) Numerical / chronological range filtering C<{ block => 4, min => 1000, max => 2000 }>.
=back
my $counts = $adb->field_fltkeys("catalog_product", {
target_block => 2,
filter => { 1 => "5" },
range => { block => "price", min => 1000 },
base_ids => \@scoped_ids,
});
=head2 field_allfltkeys($tableid, [\%options])
Calculates facet key counts across multiple configured blocks from unified C<.fac> in a single pass.
Options:
=over 4
=item * C<target_blocks>: (Required, alias: C<blocks>) Array reference of block indices to aggregate facet counts for.
=item * C<base_ids>: (Optional, alias: C<scope_ids>) Array reference of record IDs to scope calculation to.
=item * C<range>: (Optional) Numerical / chronological range filtering C<{ block => 4, min => 1000, max => 2000 }>.
=back
my $all = $adb->field_allfltkeys("catalog_product", {
target_blocks => [ 1, 2, 4 ],
base_ids => \@scoped_ids,
});
Legacy invocation C<$adb->field_allfltkeys($tableid, \@blk_list, \@base_scope)> remains fully supported.
=head2 facet_rules($table_info, @record)
Evaluates whether a record qualifies for inclusion in facet index files. Automatically integrates with C<junk_rules>.
=head1 AUTHOR
Maruf Cetin <marufcetin@gmail.com>
=head1 LICENSE AND COPYRIGHT
Copyright (C) 2020-2026 Maruf Cetin.
This library is free software; you can redistribute it and/or modify it under the terms of the Artistic License 2.0.
=cut
1;
( run in 1.013 second using v1.01-cache-2.11-cpan-e623d60df62 )