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:
commit
d2680d9afd
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
27
LICENSE.md
Normal file
27
LICENSE.md
Normal 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
15
README.md
Normal 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.
|
212
flask_sqlalchemy_webquery/__init__.py
Normal file
212
flask_sqlalchemy_webquery/__init__.py
Normal 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)
|
@ -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 %}
|
Loading…
Reference in New Issue
Block a user