diff --git a/README.rst b/README.rst index 5b2c177..1d82172 100644 --- a/README.rst +++ b/README.rst @@ -236,6 +236,23 @@ In this example we've defined a custom GroupNameField that sorts suggestions for group names by popularity (no. of users in a group) instead of default alphabetical sorting. +**Set value suggestions limit** + +By default, field value suggestions number is limited to 50 entries. +If you want to change this, override ``suggestions_limit`` field in your custom schema. + +.. code:: python + + from djangoql.schema import DjangoQLSchema, StrField + + + class UserQLSchema(DjangoQLSchema): + exclude = (Book,) + suggest_options = { + Group: ['name'], + } + suggestions_limit = 30 # Set a desired limit here + **Custom search lookup** DjangoQL base fields provide two basic methods that you can override to diff --git a/djangoql/admin.py b/djangoql/admin.py index 1f42608..1c2a098 100644 --- a/djangoql/admin.py +++ b/djangoql/admin.py @@ -123,6 +123,11 @@ def get_urls(self): )), name='djangoql_syntax_help', ), + url( + r'^suggestions/$', + self.admin_site.admin_view(self.field_value_suggestions), + name='djangoql_field_value_suggestions', + ), ] return custom_urls + super(DjangoQLSearchMixin, self).get_urls() @@ -132,3 +137,12 @@ def introspect(self, request): content=json.dumps(response, indent=2), content_type='application/json; charset=utf-8', ) + + def field_value_suggestions(self, request): + suggestions = self.djangoql_schema(self.model)\ + .get_field_instance(self.model, request.GET['field_name'])\ + .get_sugestions(request.GET['text']) + return HttpResponse( + content=json.dumps(list(suggestions), indent=2), + content_type='application/json; charset=utf-8', + ) diff --git a/djangoql/schema.py b/djangoql/schema.py index 8d164b2..2ffb4ca 100644 --- a/djangoql/schema.py +++ b/djangoql/schema.py @@ -27,7 +27,7 @@ class DjangoQLField(object): value_types_description = '' def __init__(self, model=None, name=None, nullable=None, - suggest_options=None): + suggest_options=None, suggestions_limit=None): if model is not None: self.model = model if name is not None: @@ -36,12 +36,16 @@ def __init__(self, model=None, name=None, nullable=None, self.nullable = nullable if suggest_options is not None: self.suggest_options = suggest_options + if suggestions_limit is not None: + self.suggestions_limit = suggestions_limit + + def _get_options_queryset(self): + return self.model.objects.order_by(self.name) def as_dict(self): return { 'type': self.type, 'nullable': self.nullable, - 'options': list(self.get_options()) if self.suggest_options else [], } def _field_choices(self): @@ -54,15 +58,27 @@ def _field_choices(self): def get_options(self): """ - Override this method to provide custom suggestion options + DEPRECATED: field value suggestions are now using get_sugestions() method """ choices = self._field_choices() if choices: return [c[1] for c in choices] else: - return self.model.objects.\ - order_by(self.name).\ - values_list(self.name, flat=True) + return self._get_options_queryset().values_list(self.name, flat=True) + + def get_sugestions(self, text): + """ + Override this method to provide custom suggestion options + """ + choices = self._field_choices() + if choices: + return [c[1] for c in choices] + + kwargs = {'{}__icontains'.format(self.name): text} + + return self._get_options_queryset()\ + .filter(**kwargs)[:self.suggestions_limit]\ + .values_list(self.name, flat=True) def get_lookup_name(self): """ @@ -267,12 +283,13 @@ class RelationField(DjangoQLField): type = 'relation' def __init__(self, model, name, related_model, nullable=False, - suggest_options=False): + suggest_options=False, suggestions_limit=None): super(RelationField, self).__init__( model=model, name=name, nullable=nullable, suggest_options=suggest_options, + suggestions_limit=suggestions_limit, ) self.related_model = related_model @@ -290,6 +307,7 @@ class DjangoQLSchema(object): include = () # models to include into introspection exclude = () # models to exclude from introspection suggest_options = None + suggestions_limit = 50 def __init__(self, model): if not inspect.isclass(model) or not issubclass(model, models.Model): @@ -397,6 +415,7 @@ def get_field_instance(self, model, field_name): field_kwargs['suggest_options'] = ( field.name in self.suggest_options.get(model, []) ) + field_kwargs['suggestions_limit'] = self.suggestions_limit field_instance = field_cls(**field_kwargs) # Check if suggested options conflict with field type if field_cls != StrField and field_instance.suggest_options: diff --git a/djangoql/static/djangoql/js/completion.js b/djangoql/static/djangoql/js/completion.js index 6742ef7..3d14135 100644 --- a/djangoql/static/djangoql/js/completion.js +++ b/djangoql/static/djangoql/js/completion.js @@ -807,9 +807,12 @@ }.bind(this); } this.highlightCaseSensitive = this.valuesCaseSensitive; - this.suggestions = field.options.map(function (f) { - return suggestion(f, snippetBefore, snippetAfter); - }); + this.loadSuggestions( + context.prefix, context.field, function (responseData) { + this.suggestions = responseData.map(function (f) { + return suggestion(f, snippetBefore, snippetAfter); + }); + }.bind(this)); } else if (field.type === 'bool') { this.suggestions = [ suggestion('True', '', ' '), @@ -842,6 +845,24 @@ } else { this.selected = null; } + }, + // Load suggestions from backend + loadSuggestions: function (text, fieldName, callback) { + var xhr = new XMLHttpRequest(); + var params = new URLSearchParams(); + params.set('text', text); + params.set('field_name', fieldName); + xhr.open( + 'GET', + this.options.suggestionsUrl + params.toString(), + true + ); + xhr.onload = function () { + if (xhr.readyState === 4 && xhr.status === 200) { + callback(JSON.parse(xhr.responseText)); + } + }; + xhr.send(); } }; diff --git a/djangoql/static/djangoql/js/completion_admin.js b/djangoql/static/djangoql/js/completion_admin.js index f354360..8e855a9 100644 --- a/djangoql/static/djangoql/js/completion_admin.js +++ b/djangoql/static/djangoql/js/completion_admin.js @@ -95,6 +95,7 @@ djangoQL = new DjangoQL({ completionEnabled: QLEnabled, introspections: 'introspect/', + suggestionsUrl: 'suggestions/?', syntaxHelp: 'djangoql-syntax/', selector: 'textarea[name=q]', autoResize: true diff --git a/test_project/core/admin.py b/test_project/core/admin.py index f0fffa2..c577163 100644 --- a/test_project/core/admin.py +++ b/test_project/core/admin.py @@ -5,7 +5,7 @@ from django.utils.timezone import now from djangoql.admin import DjangoQLSearchMixin -from djangoql.schema import DjangoQLSchema, IntField +from djangoql.schema import DjangoQLSchema, IntField, StrField from .models import Book @@ -89,8 +89,9 @@ def years_ago(self, n): class UserQLSchema(DjangoQLSchema): exclude = (Book,) suggest_options = { - Group: ['name'], + Group: ['name'], User: ['username'] } + suggestions_limit = 30 def get_fields(self, model): fields = super(UserQLSchema, self).get_fields(model) diff --git a/test_project/core/templates/completion_demo.html b/test_project/core/templates/completion_demo.html index 1cfb27e..ca76a44 100644 --- a/test_project/core/templates/completion_demo.html +++ b/test_project/core/templates/completion_demo.html @@ -40,7 +40,10 @@ // doesn't fit, and shrink back when text is removed. The purpose // of this is to see full search query without scrolling, could be // helpful for really long queries. - autoResize: true + autoResize: true, + + // An URL to load field value suggestions + suggestionsUrl: 'suggestions/?' }); }); diff --git a/test_project/core/tests/test_schema.py b/test_project/core/tests/test_schema.py index 2bb587f..d3168bc 100644 --- a/test_project/core/tests/test_schema.py +++ b/test_project/core/tests/test_schema.py @@ -166,3 +166,37 @@ def test_validation_fail(self): self.fail('This query should\'t pass validation: %s' % query) except DjangoQLSchemaError as e: pass + + +class UserQLSchema(DjangoQLSchema): + include = (User,) + suggestions_limit = 30 + + +class BookQLSchema(DjangoQLSchema): + include = (Book,) + suggestions_limit = 2 + + +class DjangoQLFieldTest(TestCase): + def setUp(self): + for i in range(2, 70): + User.objects.create(username='a' * i) + + def test_get_suggestions_limit_doesnt_affect_choices(self): + result = BookQLSchema(Book).get_field_instance(Book, 'genre').get_sugestions('blah') + self.assertEqual(len(result), len(Book.GENRES)) + + def test_get_suggestions_with_string_field_should_be_limited(self): + suggestions = IncludeUserGroupSchema(User).get_field_instance(User, 'username').get_sugestions('aaa') + self.assertEqual(len(suggestions), 50) + + def test_get_suggestions_with_custom_limit(self): + suggestions = UserQLSchema(User).get_field_instance(User, 'username').get_sugestions('aaa') + self.assertEqual(len(suggestions), UserQLSchema.suggestions_limit) + + def test_suggestions_should_contain_query_string(self): + query_string = 'aaa' + suggestions = UserQLSchema(User).get_field_instance(User, 'username').get_sugestions(query_string) + for suggestion in suggestions: + self.assertIn(query_string, suggestion) diff --git a/test_project/core/views.py b/test_project/core/views.py index 90fccee..2b0d506 100644 --- a/test_project/core/views.py +++ b/test_project/core/views.py @@ -3,6 +3,7 @@ from django.contrib.auth.models import Group, User from django.shortcuts import render from django.views.decorators.http import require_GET +from django.http.response import HttpResponse from djangoql.exceptions import DjangoQLError from djangoql.queryset import apply_search @@ -30,3 +31,14 @@ def completion_demo(request): 'search_results': query, 'introspections': json.dumps(UserQLSchema(query.model).as_dict()), }) + + +@require_GET +def suggestions(request): + payload = UserQLSchema(User) \ + .get_field_instance(User, request.GET['field_name']) \ + .get_sugestions(request.GET['text']) + return HttpResponse( + content=json.dumps(list(payload), indent=2), + content_type='application/json; charset=utf-8', + ) diff --git a/test_project/test_project/urls.py b/test_project/test_project/urls.py index e189557..734dbb3 100644 --- a/test_project/test_project/urls.py +++ b/test_project/test_project/urls.py @@ -17,12 +17,13 @@ from django.conf.urls import url, include from django.contrib import admin -from core.views import completion_demo +from core.views import completion_demo, suggestions urlpatterns = [ url(r'^admin/', admin.site.urls), url(r'^$', completion_demo), + url(r'^suggestions/', suggestions), ] if settings.DEBUG and settings.DJDT: