Commit cadf5bc9 authored by Serge S. Koval's avatar Serge S. Koval

Simple Django-like search for SQLa models.

parent 0a239f91
......@@ -10,14 +10,16 @@
- Built-in filtering support
- Configurable operations (=, >, <, etc)
- Callable operations
- Custom paginator class?
- Built-in search support
- Paginator class
- Custom CSS/JS in admin interface
- SQLA Model Admin
- Validation of the joins in the query
- Built-in filtering support
- Built-in search support
- Support for related models
- Many2Many support
- Verify if it is working properly
- WYSIWYG editor support
- WYSIWYG editor support?
- File admin
- Header title
- Mass-delete functionality
......
......@@ -25,6 +25,9 @@
.. autoattribute:: BaseModelView.list_columns
.. autoattribute:: BaseModelView.rename_columns
.. autoattribute:: BaseModelView.sortable_columns
.. autoattribute:: ModelView.searchable_columns
.. autoattribute:: BaseModelView.form_columns
.. autoattribute:: BaseModelView.form_args
......@@ -58,6 +61,8 @@
.. automethod:: ModelView.get_create_form
.. automethod:: ModelView.get_edit_form
.. automethod:: ModelView.init_search
Data
----
......@@ -88,4 +93,5 @@
------------
.. automethod:: ModelView._get_url
.. automethod:: ModelView.scaffold_auto_joins
\ No newline at end of file
.. automethod:: ModelView.scaffold_auto_joins
.. automethod:: ModelView.is_text_column_type
......@@ -25,6 +25,9 @@
.. autoattribute:: BaseModelView.list_columns
.. autoattribute:: BaseModelView.rename_columns
.. autoattribute:: BaseModelView.sortable_columns
.. autoattribute:: BaseModelView.searchable_columns
.. autoattribute:: BaseModelView.form_columns
.. autoattribute:: BaseModelView.form_args
......@@ -51,6 +54,8 @@
.. automethod:: BaseModelView.get_create_form
.. automethod:: BaseModelView.get_edit_form
.. automethod:: BaseModelView.init_search
Data
----
......
......@@ -58,6 +58,8 @@ class PostAdmin(sqlamodel.ModelView):
# Rename 'title' columns to 'Post Title' in list view
rename_columns = dict(title='Post Title')
searchable_columns = ('title', User.username)
# Pass arguments to WTForms. In this case, change label for text field to
# be 'Big Text' and add required() validator.
form_args = dict(
......
......@@ -2,6 +2,7 @@ from sqlalchemy.orm.attributes import InstrumentedAttribute
from sqlalchemy.orm.exc import NoResultFound
from sqlalchemy.orm import subqueryload
from sqlalchemy.sql.expression import desc
from sqlalchemy import or_
from wtforms import ValidationError, fields, validators
from wtforms.ext.sqlalchemy.orm import model_form, converts, ModelConverter
......@@ -187,6 +188,38 @@ class ModelView(BaseModelView):
Please refer to the `subqueryload` on list of possible values.
"""
searchable_columns = None
"""
Collection of the searchable columns. Only text-based columns
are searchable (`String`, `Unicode`, `Text`, `UnicodeText`).
Example::
class MyModelView(ModelView):
searchable_columns = ('name', 'email')
You can also pass columns::
class MyModelView(ModelView):
searchable_columns = (User.name, User.email)
Following search rules apply:
- If you enter *ZZZ* in the UI search field, it will generate *ILIKE '%ZZZ%'*
statement against searchable columns.
- If you enter multiple words, each word will be searched separately, but
only rows that contain all words will be displayed. For example, searching
for 'abc def' will find all rows that contain 'abc' and 'def' in one or
more columns.
- If you prefix your search term with ^, it will find all rows
that start with ^. So, if you entered *^ZZZ*, *ILIKE 'ZZZ%'* will be used.
- If you prefix your search term with =, it will do exact match.
For example, if you entered *=ZZZ*, *ILIKE 'ZZZ'* statement will be used.
"""
def __init__(self, model, session,
name=None, category=None, endpoint=None, url=None):
"""
......@@ -207,6 +240,10 @@ class ModelView(BaseModelView):
"""
self.session = session
self._search_fields = None
self._search_joins = None
self._search_joins_names = None
super(ModelView, self).__init__(model, name, category, endpoint, url)
# Configuration
......@@ -217,6 +254,9 @@ class ModelView(BaseModelView):
# Internal API
def _get_model_iterator(self):
"""
Return property iterator for the model
"""
return self.model._sa_class_manager.mapper.iterate_properties
# Scaffolding
......@@ -266,6 +306,57 @@ class ModelView(BaseModelView):
return columns
def init_search(self):
"""
Initialize search. Returns `True` if search is supported for this
view.
For SQLAlchemy, this will initialize internal fields: list of
column objects used for filtering, etc.
"""
if self.searchable_columns:
self._search_fields = []
self._search_joins = []
self._search_joins_names = set()
for p in self.searchable_columns:
# If item is a stirng, resolve it as an attribute
if isinstance(p, basestring):
attr = getattr(self.model, p, None)
else:
attr = p
# Only column searches are supported
if (not attr or
not hasattr(attr, 'property') or
not hasattr(attr.property, 'columns')):
raise Exception('Invalid searchable column "%s"' % p)
for column in attr.property.columns:
column_type = type(column.type).__name__
if not self.is_text_column_type(column_type):
raise Exception('Can only search on text columns. ' +
'Failed to setup search for "%s"' % p)
self._search_fields.append(column)
# If it belongs to different table - add a join
if column.table != self.model.__table__:
self._search_joins.append(column.table)
self._search_joins_names.add(column.table.name)
return bool(self.searchable_columns)
def is_text_column_type(self, name):
"""
Verify if column type is text-based.
Returns `True` for `String`, `Unicode`, `Text`, `UnicodeText`
"""
return (name == 'String' or name == 'Unicode' or
name == 'Text' or name == 'UnicodeText')
def scaffold_form(self):
"""
Create form from the model.
......@@ -297,7 +388,7 @@ class ModelView(BaseModelView):
return joined
# Database-related API
def get_list(self, page, sort_column, sort_desc, execute=True):
def get_list(self, page, sort_column, sort_desc, search, execute=True):
"""
Return models from the database.
......@@ -307,11 +398,42 @@ class ModelView(BaseModelView):
Sort column name
`sort_desc`
Descending or ascending sort
`search`
Search query
`execute`
Execute query immediately? Default is `True`
"""
# Will contain names of joined tables to avoid duplicate joins
joins = set()
query = self.session.query(self.model)
# Apply search before counting results
if self._search_supported and search:
# Apply search-related joins
if self._search_joins:
query = query.join(*self._search_joins)
joins |= self._search_joins_names
# Apply terms
terms = search.split(' ')
for term in terms:
if not term:
continue
if term.startswith('^'):
stmt = '%s%%' % term[1:]
elif term.startswith('='):
stmt = term[1:]
else:
stmt = '%%%s%%' % term
filter_stmt = [c.ilike(stmt) for c in self._search_fields]
query = query.filter(or_(*filter_stmt))
# Calculate number of rows
count = query.count()
# Auto join
......@@ -329,9 +451,16 @@ class ModelView(BaseModelView):
# contains dot.
if '.' in sort_field:
parts = sort_field.split('.', 1)
query = query.join(parts[0])
if parts[0] not in joins:
query = query.join(parts[0])
joins.add(parts[0])
elif isinstance(sort_field, InstrumentedAttribute):
query = query.join(sort_field.parententity)
table = sort_field.parententity.tables[0]
if table.name not in joins:
query = query.join(table)
joins.add(table.name)
else:
sort_field = None
......
......@@ -85,6 +85,18 @@ class BaseModelView(BaseView):
sortable_columns = ('name', ('user', User.username))
"""
searchable_columns = None
"""
Collection of the searchable columns. It is assumed that only
text-only fields are searchable, but it is up for a model implementation
to make decision.
For example::
class MyModelView(BaseModelView):
searchable_columns = ('name', 'email')
"""
form_columns = None
"""
Collection of the model field names for the form. If set to `None` will
......@@ -160,6 +172,8 @@ class BaseModelView(BaseView):
self._create_form_class = self.get_create_form()
self._edit_form_class = self.get_edit_form()
self._search_supported = self.init_search()
# Public API
def scaffold_list_columns(self):
"""
......@@ -225,6 +239,13 @@ class BaseModelView(BaseView):
return result
def init_search(self):
"""
Initialize search. If data provider does not support search,
`init_search` will return `False`.
"""
return False
def scaffold_form(self):
"""
Create `form.BaseForm` inherited class from the model. Must be implemented in
......@@ -284,7 +305,7 @@ class BaseModelView(BaseView):
return self._list_columns[idx]
# Database-related API
def get_list(self, page, sort_field, sort_desc):
def get_list(self, page, sort_field, sort_desc, search):
"""
Return list of models from the data source with applied pagination
and sorting.
......@@ -297,6 +318,8 @@ class BaseModelView(BaseView):
Sort column name or None.
`sort_desc`
If set to True, sorting is in descending order.
`search`
Search query
"""
raise NotImplemented('Please implement get_list method')
......@@ -373,10 +396,11 @@ class BaseModelView(BaseView):
page = request.args.get('page', 0, type=int)
sort = request.args.get('sort', None, type=int)
sort_desc = request.args.get('desc', None, type=int)
search = request.args.get('search', None)
return page, sort, sort_desc
return page, sort, sort_desc, search
def _get_url(self, view, page, sort, sort_desc):
def _get_url(self, view=None, page=None, sort=None, sort_desc=None, search=None):
"""
Generate page URL with current page, sort column and
other parameters.
......@@ -389,8 +413,17 @@ class BaseModelView(BaseView):
Sort column index
`sort_desc`
Use descending sorting order
`search`
Search query
"""
return url_for(view, page=page, sort=sort, desc=sort_desc)
if not search:
search = None
return url_for(view,
page=page,
sort=sort,
desc=sort_desc,
search=search)
# Views
@expose('/')
......@@ -399,7 +432,7 @@ class BaseModelView(BaseView):
List view
"""
# Grab parameters from URL
page, sort_idx, sort_desc = self._get_extra_args()
page, sort_idx, sort_desc, search = self._get_extra_args()
# Map column index to column name
sort_column = self._get_column_by_idx(sort_idx)
......@@ -407,7 +440,7 @@ class BaseModelView(BaseView):
sort_column = sort_column[0]
# Get count and data
count, data = self.get_list(page, sort_column, sort_desc)
count, data = self.get_list(page, sort_column, sort_desc, search)
# Calculate number of pages
num_pages = count / self.page_size
......@@ -420,7 +453,7 @@ class BaseModelView(BaseView):
if p == 0:
p = None
return self._get_url('.index_view', p, sort_idx, sort_desc)
return self._get_url('.index_view', p, sort_idx, sort_desc, search)
def sort_url(column, invert=False):
desc = None
......@@ -428,7 +461,7 @@ class BaseModelView(BaseView):
if invert and not sort_desc:
desc = 1
return self._get_url('.index_view', page, column, desc)
return self._get_url('.index_view', page, column, desc, search)
def get_value(obj, field):
return getattr(obj, field, None)
......@@ -440,7 +473,11 @@ class BaseModelView(BaseView):
sortable_columns=self._sortable_columns,
# Stuff
get_value=get_value,
return_url=self._get_url('.index_view', page, sort_idx, sort_desc),
return_url=self._get_url('.index_view',
page,
sort_idx,
sort_desc,
search),
# Pagination
pager_url=pager_url,
num_pages=num_pages,
......@@ -448,7 +485,15 @@ class BaseModelView(BaseView):
# Sorting
sort_column=sort_idx,
sort_desc=sort_desc,
sort_url=sort_url
sort_url=sort_url,
# Search
search_supported=self._search_supported,
clear_search_url=self._get_url('.index_view',
None,
sort_idx,
sort_desc,
None),
search=search
)
@expose('/new/', methods=('GET', 'POST'))
......
......@@ -2,6 +2,24 @@
{% import 'admin/lib.html' as lib %}
{% block body %}
{% if search_supported %}
<form method="GET" action="{{ return_url }}" class="well form-search">
{% if search %}
<a href="{{ clear_search_url }}">
<i class="icon-remove"></i>
</a>
{% endif %}
{% if sort_column is not none %}
<input type="hidden" name="sort" value="{{ sort_column }}"></input>
{% endif %}
{% if sort_desc %}
<input type="hidden" name="desc" value="{{ sort_desc }}"></input>
{% endif %}
<input type="text" name="search" value="{{ search or '' }}" class="span10 search-query"></input>
<button type="submit" class="btn">Search</button>
</form>
{% endif %}
<table class="table table-striped table-bordered model-list">
<thead>
<tr>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment