From bbcbf6ab3d8fa54bef11aeb4e5888292007a37be Mon Sep 17 00:00:00 2001 From: stefan Date: Mon, 3 Feb 2025 22:18:55 +0100 Subject: [PATCH 1/2] added basic search lexer and parser enabling search for parameters and attributes - could be extended the error handling is very basic, but effective some missing features like ordering ...and maybe a potential security as the search will allow search for all attributes somehow related to the component model --- .gitmodules | 3 + .../parts/views/component_views.py | 24 ++- .../ShimattaSearchLanguage.py | 184 ++++++++++++++++++ .../templates/parts/components.html | 28 ++- sly | 1 + 5 files changed, 224 insertions(+), 16 deletions(-) create mode 100644 .gitmodules create mode 100644 shimatta_kenkyusho/shimatta_modules/ShimattaSearchLanguage.py create mode 160000 sly diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..73052c8 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "sly"] + path = sly + url = git@git.shimatta.de:sst/sly.git diff --git a/shimatta_kenkyusho/parts/views/component_views.py b/shimatta_kenkyusho/parts/views/component_views.py index d092c84..c694d84 100644 --- a/shimatta_kenkyusho/parts/views/component_views.py +++ b/shimatta_kenkyusho/parts/views/component_views.py @@ -1,4 +1,3 @@ -import uuid from django.shortcuts import redirect from django.urls import reverse from django.views.generic import TemplateView, DetailView @@ -8,6 +7,7 @@ from django.db.models import Q from django.forms import formset_factory from django.db import IntegrityError from django.db.models import ProtectedError +from shimatta_modules.ShimattaSearchLanguage import ShimattaSearchLanguage from ..models import Stock, Component, ComponentParameter, DistributorNum, PackageParameter from ..forms import * from .component_import import import_components_from_csv @@ -26,15 +26,23 @@ class ComponentView(LoginRequiredMixin, BaseTemplateMixin, TemplateView): def get_component_query_set(self, search_string): queryset = Component.objects.all() + error_string = '' if not search_string: return queryset + + search_parser = ShimattaSearchLanguage() + query, errors = search_parser.search_for_components(search_string) - search_fragments = search_string.strip().split() - for search in search_fragments: - queryset = queryset.filter(Q(name__icontains = search) | Q(manufacturer__name__icontains = search) | Q(package__name__icontains = search)) + if query: + try: + queryset = queryset.filter(query) + except Exception as ex: + error_string = str(ex) + else: + error_string = '

'.join(errors) - return queryset + return queryset, error_string def get_component_queryset_from_advanced_search(self, cleaned_data): queryset = Component.objects.all() @@ -60,6 +68,8 @@ class ComponentView(LoginRequiredMixin, BaseTemplateMixin, TemplateView): def get_context_data_int(self, advanced_search, parameter_formset : ParameterSearchFormSet, **kwargs): context = super().get_context_data(**kwargs) + errors = '' + comp_page_num = self.request.GET.get('comp_page', default=1) if advanced_search and parameter_formset: @@ -79,7 +89,7 @@ class ComponentView(LoginRequiredMixin, BaseTemplateMixin, TemplateView): else: search = self.request.GET.get('search', default=None) - paginator_queryset = self.get_component_query_set(search) + paginator_queryset, errors = self.get_component_query_set(search) comp_paginator = Paginator(paginator_queryset, self.default_page_size) @@ -87,11 +97,11 @@ class ComponentView(LoginRequiredMixin, BaseTemplateMixin, TemplateView): context['comp_form'] = ComponentForm() context['import_comp_form'] = ImportComponentForm() context['search_string'] = search + context['errors'] = errors if not parameter_formset: context['advanced_search_param_formset'] = ParameterSearchFormSet() - if not advanced_search: context['advanced_search_form'] = AdvancedComponentSearchForm(auto_id='adv_search_%s') diff --git a/shimatta_kenkyusho/shimatta_modules/ShimattaSearchLanguage.py b/shimatta_kenkyusho/shimatta_modules/ShimattaSearchLanguage.py new file mode 100644 index 0000000..d21c8b1 --- /dev/null +++ b/shimatta_kenkyusho/shimatta_modules/ShimattaSearchLanguage.py @@ -0,0 +1,184 @@ +import re +from django.db.models import Q +from sly import Lexer, Parser +from shimatta_modules.EngineeringNumberConverter import EngineeringNumberConverter + +class ShimattaSearchConstants(): + ''' + Just a bunch of conversions and regular expression stored here. + ''' + + # convert the prefixes from the engineering number converter be used in the lexer + PREFIX_DICT = {k: v for k, v in EngineeringNumberConverter.prefixes if k} + PREFIX_DICT.update({k: v for k, v in EngineeringNumberConverter.it_prefixes if k}) + PREFIX_RULE = r'(' + r'|'.join([rf'(?:{p})' for p in PREFIX_DICT.keys()]) + r')?' + + TEXTUAL_REGEX = r'(?:([^"\s]+))|(?:"([^"]*)")' + VALUE_REGEX = rf'(\-?\d+(?:\.\d+)?){PREFIX_RULE}' + + TEXTUAL_PATTERN = re.compile(TEXTUAL_REGEX) + VALUE_PATTERN = re.compile(VALUE_REGEX) + +class ShimattaSearchLexer(Lexer): + ''' + Stupid lexer to tokenize a search string. + ''' + + tokens = {GT, LT, GTE, LTE, EQ, NEQ, AND, OR, LPAREN, RPAREN, NUMBER, TEXTUAL} + + # ignore whitespace only characters as well as newlines + ignore = ' \t\n' + + # Regular expression rules for simple tokens + GT = r'>' + LT = r'<' + GTE = r'>=' + LTE = r'<=' + EQ = r'==' + NEQ = r'!=' + AND = r'&' + OR = r'\|' + LPAREN = r'\(' + RPAREN = r'\)' + + def __init__(self): + self.errors = [] + super().__init__() + + @_(ShimattaSearchConstants.VALUE_REGEX) + def NUMBER(self, t): + ''' + Parse numbers with engineering unit prefixes + ''' + match = ShimattaSearchConstants.VALUE_PATTERN.match(t.value) + t.value = float(match.group(1)) + prefix = match.group(2) + if prefix: + t.value *= ShimattaSearchConstants.PREFIX_DICT[prefix] + return t + + @_(ShimattaSearchConstants.TEXTUAL_REGEX) + def TEXTUAL(self, t): + ''' + Find texts with or without param_ prefix (used to filter for parameters) + ''' + match = ShimattaSearchConstants.TEXTUAL_PATTERN.match(t.value) + + # strip the quotation marks + value = match.group(1) + if match.group(2): + value = match.group(2) + + t.value = value + return t + + def error(self, t): + self.errors.append(f'Line {self.lineno}: Bad character {t.value}') + self.index += 1 + +class ShimattaSearchParser(Parser): + # Get the token list from the lexer (required) + tokens = ShimattaSearchLexer.tokens + + def __init__(self): + self.errors = [] + super().__init__() + + @staticmethod + def _get_filter(key, value, compare_suffix='', invert=False): + ''' + Assemble a filter to grep data from the relational database structure + ''' + + # filter for params - stored in two separate tables + if key.startswith('param_'): + key = key[len('param_'):] + + key_query = Q(Q(**{f'componentparameter__parameter_type__parameter_name': key})| \ + Q(**{f'package__packageparameter__parameter_type__parameter_name': key})) + + if isinstance(value, str): + query = Q(Q(**{f'componentparameter__text_value{compare_suffix}': value})| \ + Q(**{f'package__packageparameter__text_value{compare_suffix}': value}))&key_query + else: + query = Q(Q(**{f'componentparameter__value{compare_suffix}': value})| \ + Q(**{f'package__packageparameter__value{compare_suffix}': value}))&key_query + # filter for direct attributes - or whatever the user throws into the search input + else: + query = Q(**{f'{key}{compare_suffix}': value}) + + if invert: + query = ~query + return Q(query) + + # ruleset + + @_('expression : textual GT number') + def expression(self, p): + return self._get_filter(p.textual.strip(), p.number, '__gt', False) + + @_('expression : textual LT number') + def expression(self, p): + return self._get_filter(p.textual.strip(), p.number, '__lt', False) + + @_('expression : textual GTE number') + def expression(self, p): + return self._get_filter(p.textual.strip(), p.number, '__gte', False) + + @_('expression : textual LTE number') + def expression(self, p): + return self._get_filter(p.textual.strip(), p.number, '__lte', False) + + @_('expression : textual EQ number') + def expression(self, p): + return self._get_filter(p.textual.strip(), p.number, '', False) + + @_('expression : textual NEQ number') + def expression(self, p): + return self._get_filter(p.textual.strip(), p.number, '', True) + + @_('expression : textual EQ textual') + def expression(self, p): + return self._get_filter(p.textual0.strip(), p.textual1, '', False) + + @_('expression : textual NEQ textual') + def expression(self, p): + return self._get_filter(p.textual0.strip(), p.textual1, '', True) + + @_('TEXTUAL') + def textual(self, p): + return p.TEXTUAL + + @_('NUMBER') + def number(self, p): + return p.NUMBER + + @_('expression : LPAREN expression RPAREN') + def expression(self, p): + return Q(p.expression) + + @_('expression : expression AND expression') + def expression(self, p): + return p.expression0&p.expression1 + + @_('expression : expression OR expression') + def expression(self, p): + return p.expression0|p.expression1 + + @_('expression') + def expression(self, p): + return p.expression + + # Error rule for syntax errors + def error(self, p): + self.errors.append(f'Syntax error in input {p}!') + +class ShimattaSearchLanguage(): + + def __init__(self): + self.lexer = ShimattaSearchLexer() + self.parser = ShimattaSearchParser() + + def search_for_components(self, search_string): + query = self.parser.parse(self.lexer.tokenize(search_string)) + return query, self.lexer.errors + self.parser.errors diff --git a/shimatta_kenkyusho/templates/parts/components.html b/shimatta_kenkyusho/templates/parts/components.html index f4011ac..e4dc496 100644 --- a/shimatta_kenkyusho/templates/parts/components.html +++ b/shimatta_kenkyusho/templates/parts/components.html @@ -6,16 +6,26 @@

Components

-
-
- - - - - + + {% if errors %} +
+ {% endif %} +
+ + + + + +
+ {% if errors %} +
+
Error in search query:
+ {{ errors }} +
+ {% endif %}
diff --git a/sly b/sly new file mode 160000 index 0000000..539a85a --- /dev/null +++ b/sly @@ -0,0 +1 @@ +Subproject commit 539a85a5d5818bf4e1cb5a9e749d6e2fab70a351 -- 2.47.0 From 8a676c096a2fbf86f559e1731abfa73c5ef1ee63 Mon Sep 17 00:00:00 2001 From: stefan Date: Mon, 3 Feb 2025 22:28:25 +0100 Subject: [PATCH 2/2] report search errors even on partial success --- shimatta_kenkyusho/parts/views/component_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shimatta_kenkyusho/parts/views/component_views.py b/shimatta_kenkyusho/parts/views/component_views.py index c694d84..de80bce 100644 --- a/shimatta_kenkyusho/parts/views/component_views.py +++ b/shimatta_kenkyusho/parts/views/component_views.py @@ -34,7 +34,7 @@ class ComponentView(LoginRequiredMixin, BaseTemplateMixin, TemplateView): search_parser = ShimattaSearchLanguage() query, errors = search_parser.search_for_components(search_string) - if query: + if query and not errors: try: queryset = queryset.filter(query) except Exception as ex: -- 2.47.0