Implement Phase 3: Multi-attribute search AND logic and Phase 4: Sort by attribute columns

Phase 3 - Multi-attribute Search:
- Add parse_attribute_search() method to parse search syntax like
  'color: blue size: large' or 'color:blue AND size:large'
- Update search_by_attributes() to support AND/OR logic for multiple
  attribute queries
- Add search_by_attribute_value() private method for single value search

Phase 4 - Sort by Attribute Columns:
- Add get_attribute_sort_definition_id() method to detect attribute
  column sorting
- Update Item::search() to join attribute tables when sorting by
  attribute columns
- Update tabular_helper to make attribute columns sortable
- Add sanitizeSortColumnAttribute() method in Items controller to
  validate attribute definition IDs as sort columns

Fixes #2722 - sorting by attribute columns now works
This commit is contained in:
Ollama
2026-03-16 18:27:56 +00:00
committed by jekkos
parent 362f2925a5
commit f2fffa2723
3 changed files with 161 additions and 4 deletions

View File

@@ -63,6 +63,33 @@ class Items extends Secure_Controller
$this->config = config(OSPOS::class)->settings;
}
/**
* Sanitize sort column allowing standard columns and attribute definition IDs
*
* @param string|null $field The requested sort field
* @param string $default The default sort field
* @param array $attribute_ids Allowed attribute definition IDs
* @return string The validated sort field
*/
private function sanitizeSortColumnAttribute(?string $field, string $default, array $attribute_ids): string
{
if ($field === null) {
return $default;
}
$allowed_columns = ['items.item_id', 'item_number', 'name', 'category', 'company_name', 'cost_price', 'unit_price', 'quantity'];
if (in_array($field, $allowed_columns)) {
return $field;
}
if (ctype_digit($field) && in_array((int) $field, $attribute_ids, true)) {
return $field;
}
return $default;
}
/**
* @return string
*/
@@ -105,7 +132,11 @@ class Items extends Secure_Controller
$search = $this->request->getGet('search', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$limit = $this->request->getGet('limit', FILTER_SANITIZE_NUMBER_INT);
$offset = $this->request->getGet('offset', FILTER_SANITIZE_NUMBER_INT);
$sort = $this->sanitizeSortColumn(item_headers(), $this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'item_id');
$definition_names = $this->attribute->get_definitions_by_flags(Attribute::SHOW_IN_ITEMS);
$attribute_column_ids = array_keys($definition_names);
$sort = $this->sanitizeSortColumnAttribute($this->request->getGet('sort', FILTER_SANITIZE_FULL_SPECIAL_CHARS), 'item_id', $attribute_column_ids);
$order = $this->request->getGet('order', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
$this->item_lib->set_item_location($this->request->getGet('stock_location'));

View File

@@ -422,7 +422,7 @@ function get_items_manage_table_headers(): string
$headers[] = ['item_pic' => lang('Items.image'), 'sortable' => false];
foreach ($definitionsWithTypes as $definition_id => $definitionInfo) {
$headers[] = [$definition_id => $definitionInfo['name'], 'sortable' => false];
$headers[] = [$definition_id => $definitionInfo['name'], 'sortable' => true];
}
$headers[] = ['inventory' => '', 'escape' => false];

View File

@@ -132,6 +132,43 @@ class Item extends Model
return $this->search($search, $filters, 0, 0, 'items.name', 'asc', true);
}
/**
* Parse search string for attribute-specific queries
* Supports syntax like "color: blue size: large" or "color:blue AND size:large"
*
* @param string $search The raw search string
* @return array{terms: array, attributes: array} Parsed terms and attribute queries
*/
public function parse_attribute_search(string $search): array
{
$result = [
'terms' => [],
'attributes' => []
];
if (empty($search)) {
return $result;
}
$pattern = '/(\w+)\s*:\s*([^\s,]+)(?:\s+(?:AND|OR)\s+)?/i';
$remaining = preg_replace($pattern, '', $search);
if (preg_match_all($pattern, $search, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$attrName = strtolower(trim($match[1]));
$attrValue = trim($match[2]);
$result['attributes'][$attrName][] = $attrValue;
}
}
$remaining = trim(preg_replace('/\s+/', ' ', $remaining));
if (!empty($remaining)) {
$result['terms'][] = $remaining;
}
return $result;
}
/**
* Search for items by attribute values
* Returns an array of item_ids matching the attribute search criteria
@@ -139,14 +176,78 @@ class Item extends Model
* @param string $search Search term
* @param array $definition_ids Attribute definition IDs to search within
* @param bool $include_deleted Whether to include deleted items
* @param string $logic 'AND' or 'OR' for multiple attribute matching
* @return array Array of matching item_ids
*/
public function search_by_attributes(string $search, array $definition_ids, bool $include_deleted = false): array
public function search_by_attributes(string $search, array $definition_ids, bool $include_deleted = false, string $logic = 'OR'): array
{
if (empty($definition_ids) || empty($search)) {
return [];
}
$parsed = $this->parse_attribute_search($search);
$matching_item_ids = [];
if (!empty($parsed['attributes'])) {
$attribute = model(Attribute::class);
$all_definitions = $attribute->get_definitions_by_flags(Attribute::SHOW_IN_ITEMS | Attribute::SHOW_IN_SEARCH);
$definition_name_to_id = [];
foreach ($all_definitions as $id => $def_info) {
$definition_name_to_id[strtolower($def_info['name'])] = $id;
}
foreach ($parsed['attributes'] as $attr_name => $values) {
if (!isset($definition_name_to_id[$attr_name])) {
continue;
}
$definition_id = $definition_name_to_id[$attr_name];
foreach ($values as $value) {
$builder = $this->db->table('attribute_links');
$builder->select('DISTINCT attribute_links.item_id');
$builder->join('attribute_values', 'attribute_values.attribute_id = attribute_links.attribute_id');
$builder->join('items', 'items.item_id = attribute_links.item_id');
$builder->like('attribute_values.attribute_value', $value);
$builder->where('attribute_links.definition_id', $definition_id);
$builder->where('attribute_links.sale_id', null);
$builder->where('attribute_links.receiving_id', null);
$builder->where('items.deleted', $include_deleted);
$found_ids = array_column($builder->get()->getResultArray(), 'item_id');
if ($logic === 'AND') {
if (empty($matching_item_ids)) {
$matching_item_ids = $found_ids;
} else {
$matching_item_ids = array_intersect($matching_item_ids, $found_ids);
}
} else {
$matching_item_ids = array_unique(array_merge($matching_item_ids, $found_ids));
}
}
}
}
if (!empty($parsed['terms'])) {
$term = implode(' ', $parsed['terms']);
return $this->search_by_attribute_value($term, $definition_ids, $include_deleted);
}
return $matching_item_ids;
}
/**
* Search for items by a single attribute value
*
* @param string $search Search term
* @param array $definition_ids Attribute definition IDs to search within
* @param bool $include_deleted Whether to include deleted items
* @return array Array of matching item_ids
*/
private function search_by_attribute_value(string $search, array $definition_ids, bool $include_deleted = false): array
{
$builder = $this->db->table('attribute_links');
$builder->select('DISTINCT attribute_links.item_id');
$builder->join('attribute_values', 'attribute_values.attribute_id = attribute_links.attribute_id');
@@ -163,6 +264,21 @@ class Item extends Model
return array_column($builder->get()->getResultArray(), 'item_id');
}
/**
* Get attribute definition ID from column name for sorting
*
* @param string $sort_column The sort column name
* @return int|null The definition ID or null if not an attribute column
*/
private function get_attribute_sort_definition_id(string $sort_column): ?int
{
if (!ctype_digit($sort_column)) {
return null;
}
return (int) $sort_column;
}
/**
* Perform a search on items
*/
@@ -279,6 +395,17 @@ class Item extends Model
$builder->join('attribute_values', 'attribute_values.attribute_id = attribute_links.attribute_id', 'left');
}
// Handle attribute column sorting
$sort_definition_id = $this->get_attribute_sort_definition_id($sort);
if ($sort_definition_id !== null && $attributes_enabled && !$count_only) {
$sort_alias = "sort_attr_{$sort_definition_id}";
$builder->join("attribute_links AS {$sort_alias}", "{$sort_alias}.item_id = items.item_id AND {$sort_alias}.definition_id = {$sort_definition_id} AND {$sort_alias}.sale_id IS NULL AND {$sort_alias}.receiving_id IS NULL", 'left');
$builder->join("attribute_values AS {$sort_alias}_val", "{$sort_alias}_val.attribute_id = {$sort_alias}.attribute_id", 'left');
$builder->orderBy("{$sort_alias}_val.attribute_value", $order);
} else {
$builder->orderBy($sort, $order);
}
$builder->where('items.deleted', $filters['is_deleted']);
if ($filters['empty_upc']) {
@@ -305,7 +432,6 @@ class Item extends Model
}
$builder->groupBy('items.item_id');
$builder->orderBy($sort, $order);
if ($rows > 0) {
$builder->limit($rows, $limit_from);