Initial commit with a working version

It can already collect all the registered models and make a query on
different fields.  It displays the first 10 records matching the query.
There are currently no means to filter the data.
This commit is contained in:
Gergely Polonkai 2017-06-21 16:59:09 +02:00
commit d2680d9afd
5 changed files with 394 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
__pycache__/
*.pyc

27
LICENSE.md Normal file
View File

@ -0,0 +1,27 @@
Copyright 2017 Gravitalent Kft., Gergely Polonkai
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from this
software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE

15
README.md Normal file
View File

@ -0,0 +1,15 @@
# Flask-SQLAlchemy-WebQuery
Build SQLAlchemy queries through a web interface.
This is a generic web interface to query your SQLAlchemy models, and save the results in a CSV file.
This is a work in progress. Feel free to contribute!
# License
This library is licensed under the BSD 3 Clause license. See the LICENSE file for details.
# Contributing
If you have an idea, open an issue on GitLab.

View File

@ -0,0 +1,212 @@
from flask import current_app, Blueprint, render_template, request, jsonify
class FlaskSQLAlchemyWebquery(object):
def __init__(self, app=None):
self.models = {}
self.blueprint = None
self.db = None
if app:
self.init_app(app)
def list_models(self):
return render_template('flask_sqlalchemy_webquery/list-models.html')
def get_joins(self, from_model, to_model):
if from_model == to_model:
return []
from math import inf
Q = set()
dist = {}
prev = {}
for model, model_data in self.models.items():
# v: model
dist[model] = inf
prev[model] = None
Q.add(model)
dist[from_model] = 0
while Q:
dests = sorted(Q, key=lambda x: dist[x])
u = dests[0]
if u == to_model:
break
Q.remove(u)
for v in self.models[u]['related_tables'].keys():
if v not in Q:
continue
alt = dist[u] + 1
if alt < dist[v]:
dist[v] = alt
prev[v] = u
S = []
u = to_model
while u in prev:
S.append(u)
u = prev[u]
S.reverse()
return S
def update_view(self):
if 'model' not in request.json or \
'columns' not in request.json:
return jsonify((400, {'error': 'Missing input'}))
(model_class,) = [cls for cls, data in self.models.items() if data['table'].name == request.json['model']]
available_models = {}
to_add = [model_class]
while to_add:
model = to_add.pop()
if model in available_models:
continue
available_models[model] = self.models[model]
to_add += self.models[model]['related_tables'].keys()
columns = {}
# Collect all columns reachable from the selected model
for cls, model_data in available_models.items():
table_name = model_data['table'].name
columns[table_name] = []
for column_name, column in model_data['columns'].items():
columns[table_name].append(column_name)
joins = []
# Fetch the results
if request.json['columns']:
requested_columns = request.json['columns']
query = self.db.session.query(model_class)
for data in requested_columns:
model_name, column_name = data['model'], data['column']
target_model = self.get_model_by_table_name(model_name)
for cls in self.get_joins(model_class, target_model):
if cls not in joins and cls != model_class:
query = query.join(cls)
joins.append(cls)
result_count = query.count()
results = []
# TODO: Make this configurable
for row in query.limit(10):
res_row = {}
for data in requested_columns:
model_name, column_name = data['model'], data['column']
target_model = self.get_model_by_table_name(model_name)
res_key = ':'.join((model_name, column_name))
print(row)
if target_model == model_class:
current_value = getattr(row, column_name)
else:
current = model_class
current_value = row
for cls in self.get_joins(model_class, target_model):
if cls == model_class:
continue
next_column_name = self.models[current]['related_tables'][cls]
current_value = getattr(current_value, next_column_name)
current = cls
if cls == target_model:
current_value = getattr(current_value, column_name)
res_row[res_key] = current_value
results.append(res_row)
else:
results = None
result_count = 0
return jsonify({
'results': results,
'columns': columns,
'count': result_count
})
def get_model_by_table_name(self, table_name):
for model, model_data in self.models.items():
if model_data['table'].name == table_name:
return model
def init_app(self, app):
self.db = app.extensions['sqlalchemy'].db
self.models = {}
for cls in self.db.Model._decl_class_registry.values():
if isinstance(cls, type) and issubclass(cls, self.db.Model):
self.models[cls] = {
'table': cls.__table__,
'related_tables': {},
'columns': cls.__mapper__.columns
}
for model in self.models:
from sqlalchemy.orm.base import MANYTOONE
from sqlalchemy.orm.attributes import InstrumentedAttribute
from sqlalchemy.orm.relationships import RelationshipProperty
relationships = {}
for attr_name in model.__dict__:
attr = getattr(model, attr_name)
if isinstance(attr, InstrumentedAttribute):
if isinstance(attr.property, RelationshipProperty):
relationships[attr.property] = attr_name
for r in model.__mapper__.relationships:
# Only support many to one relationships (e.g. foreign keys)
if r.direction != MANYTOONE:
continue
for local, remote in r.local_remote_pairs:
(remote_model,) = [remote_model for remote_model, model_data in self.models.items() if model_data['table'] == remote.table]
self.models[model]['related_tables'][remote_model] = relationships[r]
self.blueprint = Blueprint('flask_sqlalchemy_webquery',
__name__,
template_folder='templates',
static_folder='static',
static_url_path=app.static_url_path + 'flask_sqlalchemy_webquery')
self.blueprint.add_url_rule('/list-models', 'list_models', self.list_models)
self.blueprint.add_url_rule('/get-columns', 'update_view', self.update_view, methods=['POST'])
@self.blueprint.context_processor
def inject_variables():
return {
'models': self.models
}
app.register_blueprint(self.blueprint)

View File

@ -0,0 +1,138 @@
{% extends 'bootstrap/base.html' %}
{% block content %}
<h1>Flask SQLAlchemy Web query</h1>
{% if models %}
<div class="container">
<div class="form-group">
<select class="form-control" id="primary_model">
<option></option>
{% for model, model_data in models.items() %}
<option value="{{ model_data.table }}">{{ model.__name__ }}</option>
{% endfor %}
</select>
</div>
</div>
{% else %}
<p class="danger">
It seems you have no models registered. Did you forget to initialise SQLAlchemy before webquery?
</p>
{% endif %}
<div id="table-container">
Please choose a primary model!
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
<script>
$(document).ready(function() {
{# TODO: Move this away, as this is Flask-WTF related! #}
var csrf_token = '{{ csrf_token() }}';
var current_columns = [];
var model = '';
$.ajaxSetup({
beforeSend: function(xhr, settings) {
if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) &&
!this.crossDomain) {
xhr.setRequestHeader('X-CSRFToken', csrf_token);
}
}
});
var header_row = $('<tr></tr>')
.on('change', 'select', function() {
var column = $(this).val().split(':');
var model = column[0];
column = column[1];
current_columns.push({model: model, column: column});
$(this).replaceWith($('<span></span>').text(model + '.' + column));
update_view();
});
var table_body = $('<tbody></tbody>')
var table = $('<table></table>')
.append($('<thead></thead>')
.append(header_row))
.append(table_body);
function add_column(data) {
var select = $('<select></select>')
.append($('<option></option>'));
var column_chooser = $('<td></td>')
.append(select);
header_row.append(column_chooser);
var prev_key = null;
var optgroup = null;
$.each(data.columns, function(key, value) {
optgroup = $('<optgroup></optgroup>')
.attr('label', key);
$.each(value, function(idx, column) {
var opt_value = key + ':' + column;
var option = $('<option></option>')
.val(opt_value)
.text(column);
optgroup.append(option);
});
select.append(optgroup);
});
}
function update_view() {
$.ajax({
type: 'post',
url: '{{ url_for('flask_sqlalchemy_webquery.update_view') }}',
contentType: 'application/json; charset=utf-8',
data: JSON.stringify({
model: model,
columns: current_columns
}),
success: function(data) {
table_body.empty()
add_column(data);
if (data.results !== null) {
$.each(data.results, function(idx, row) {
table_row = $('<tr></tr>');
$.each(current_columns, function(idx, column) {
column_key = column.model + ':' + column.column;
table_cell = $('<td></td>')
.text(row[column_key]);
table_row.append(table_cell)
});
table_body.append(table_row);
});
}
}
});
}
$('#primary_model').change(function() {
model = $(this).val();
if (model === '') return;
{# TODO: Replace model as text with something more meaningful #}
$(this).replaceWith($('<div>' + model + '</div>'));
$('#table-container').empty().append(table);
update_view();
});
});
</script>
{% endblock %}