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