-
Notifications
You must be signed in to change notification settings - Fork 208
/
Copy pathquerybuilder.py
2323 lines (1963 loc) · 99 KB
/
querybuilder.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# -*- coding: utf-8 -*-
###########################################################################
# Copyright (c), The AiiDA team. All rights reserved. #
# This file is part of the AiiDA code. #
# #
# The code is hosted on GitHub at https://github.com/aiidateam/aiida-core #
# For further information on the license, see the LICENSE.txt file #
# For further information please visit http://www.aiida.net #
###########################################################################
"""
The QueryBuilder: A class that allows you to query the AiiDA database, independent from backend.
Note that the backend implementation is enforced and handled with a composition model!
:func:`QueryBuilder` is the frontend class that the user can use. It inherits from *object* and contains
backend-specific functionality. Backend specific functionality is provided by the implementation classes.
These inherit from :func:`aiida.orm.implementation.BackendQueryBuilder`,
an interface classes which enforces the implementation of its defined methods.
An instance of one of the implementation classes becomes a member of the :func:`QueryBuilder` instance
when instantiated by the user.
"""
from __future__ import division
from __future__ import absolute_import
from __future__ import print_function
# Checking for correct input with the inspect module
from inspect import isclass as inspect_isclass
import copy
import logging
import six
from six.moves import range, zip
from sqlalchemy import and_, or_, not_, func as sa_func, select, join
from sqlalchemy.types import Integer
from sqlalchemy.orm import aliased
from sqlalchemy.sql.expression import cast
from sqlalchemy.dialects.postgresql import array
from aiida.common.exceptions import InputValidationError
# The way I get column as a an attribute to the orm class
from aiida.common.links import LinkType
from aiida.manage.manager import get_manager
from aiida.common.exceptions import ConfigurationError
from . import authinfos
from . import comments
from . import computers
from . import groups
from . import logs
from . import users
from . import entities
from . import convert
__all__ = ('QueryBuilder',)
_LOGGER = logging.getLogger(__name__)
def get_querybuilder_classifiers_from_cls(cls, qb):
"""
Return the correct classifiers for the QueryBuilder from an ORM class.
:param cls: an AiiDA ORM class or backend ORM class.
:param qb: an instance of the appropriate QueryBuilder backend.
:returns: the ORM class as well as a dictionary with additional classifier strings
:rtype: cls, dict
Note: the ormclass_type_string is currently hardcoded for group, computer etc. One could instead use something like
aiida.orm.utils.node.get_type_string_from_class(cls.__module__, cls.__name__)
"""
# Note: Unable to move this import to the top of the module for some reason
from aiida.engine import Process
from aiida.orm.utils.node import is_valid_node_type_string
classifiers = {}
classifiers['process_type_string'] = None
# Nodes
if issubclass(cls, qb.Node):
# If a backend ORM node (i.e. DbNode) is passed.
# Users shouldn't do that, by why not...
classifiers['ormclass_type_string'] = qb.AiidaNode._plugin_type_string
ormclass = cls
elif issubclass(cls, qb.AiidaNode):
classifiers['ormclass_type_string'] = cls._plugin_type_string
ormclass = qb.Node
# Groups:
elif issubclass(cls, qb.Group):
classifiers['ormclass_type_string'] = 'group'
ormclass = cls
elif issubclass(cls, groups.Group):
classifiers['ormclass_type_string'] = 'group'
ormclass = qb.Group
# Computers:
elif issubclass(cls, qb.Computer):
classifiers['ormclass_type_string'] = 'computer'
ormclass = cls
elif issubclass(cls, computers.Computer):
classifiers['ormclass_type_string'] = 'computer'
ormclass = qb.Computer
# Users
elif issubclass(cls, qb.User):
classifiers['ormclass_type_string'] = 'user'
ormclass = cls
elif issubclass(cls, users.User):
classifiers['ormclass_type_string'] = 'user'
ormclass = qb.User
# AuthInfo
elif issubclass(cls, qb.AuthInfo):
classifiers['ormclass_type_string'] = 'authinfo'
ormclass = cls
elif issubclass(cls, authinfos.AuthInfo):
classifiers['ormclass_type_string'] = 'authinfo'
ormclass = qb.AuthInfo
# Comment
elif issubclass(cls, qb.Comment):
classifiers['ormclass_type_string'] = 'comment'
ormclass = cls
elif issubclass(cls, comments.Comment):
classifiers['ormclass_type_string'] = 'comment'
ormclass = qb.Comment
# Log
elif issubclass(cls, qb.Log):
classifiers['ormclass_type_string'] = 'log'
ormclass = cls
elif issubclass(cls, logs.Log):
classifiers['ormclass_type_string'] = 'log'
ormclass = qb.Log
# Process
# This is a special case, since Process is not an ORM class.
# We need to deduce the ORM class used by the Process.
elif issubclass(cls, Process):
classifiers['ormclass_type_string'] = cls._node_class._plugin_type_string
classifiers['process_type_string'] = cls.build_process_type()
ormclass = qb.Node
else:
raise InputValidationError('I do not know what to do with {}'.format(cls))
if ormclass == qb.Node:
is_valid_node_type_string(classifiers['ormclass_type_string'], raise_on_false=True)
return ormclass, classifiers
def get_querybuilder_classifiers_from_type(ormclass_type_string, qb):
"""
Return the correct classifiers for the QueryBuilder from an ORM type string.
:param ormclass_type_string: type string for ORM class
:param qb: an instance of the appropriate QueryBuilder backend.
:returns: the ORM class as well as a dictionary with additional classifier strings
:rtype: cls, dict
Same as get_querybuilder_classifiers_from_cls, but accepts a string instead of a class.
"""
from aiida.orm.utils.node import is_valid_node_type_string
classifiers = {}
classifiers['process_type_string'] = None
classifiers['ormclass_type_string'] = ormclass_type_string.lower()
if classifiers['ormclass_type_string'] == 'group':
ormclass = qb.Group
elif classifiers['ormclass_type_string'] == 'computer':
ormclass = qb.Computer
elif classifiers['ormclass_type_string'] == 'user':
ormclass = qb.User
else:
# At this point, we assume it is a node. The only valid type string then is a string
# that matches exactly the _plugin_type_string of a node class
classifiers['ormclass_type_string'] = ormclass_type_string # no lowercase
ormclass = qb.Node
if ormclass == qb.Node:
is_valid_node_type_string(classifiers['ormclass_type_string'], raise_on_false=True)
return ormclass, classifiers
def get_type_filter(classifiers, subclassing):
"""
Return filter dictionaries given a set of classifiers.
:param classifiers: a dictionary with classifiers (note: does *not* support lists)
:param subclassing: if True, allow for subclasses of the ormclass
:returns: dictionary in QueryBuilder filter language to pass into {"type": ... }
:rtype: dict
"""
from aiida.orm.utils.node import get_query_type_from_type_string
from aiida.common.escaping import escape_for_sql_like
value = classifiers['ormclass_type_string']
if not subclassing:
filter = {'==': value}
else:
# Note: the query_type_string always ends with a dot. This ensures that "like {str}%" matches *only*
# the query type string
filter = {'like': '{}%'.format(escape_for_sql_like(get_query_type_from_type_string(value)))}
return filter
def get_process_type_filter(classifiers, subclassing):
"""
Return filter dictionaries given a set of classifiers.
:param classifiers: a dictionary with classifiers (note: does *not* support lists)
:param subclassing: if True, allow for subclasses of the process type
This is activated only, if an entry point can be found for the process type
(as well as for a selection of built-in process types)
:returns: dictionary in QueryBuilder filter language to pass into {"process_type": ... }
:rtype: dict
"""
from aiida.common.escaping import escape_for_sql_like
from aiida.common.warnings import AiidaEntryPointWarning
from aiida.engine.processes.process import get_query_string_from_process_type_string
import warnings
value = classifiers['process_type_string']
if not subclassing:
filter = {'==': value}
else:
if ':' in value:
# if value is an entry point, do usual subclassing
# Note: the process_type_string stored in the database does *not* end in a dot.
# In order to avoid that querying for class 'Begin' will also find class 'BeginEnd',
# we need to search separately for equality and 'like'.
filter = {'or': [
{'==': value},
{'like': escape_for_sql_like(get_query_string_from_process_type_string(value))},
]}
elif value.startswith('aiida.engine'):
# For core process types, a filter is not is needed since each process type has a corresponding
# ormclass type that already specifies everything.
# Note: This solution is fragile and will break as soon as there is not an exact one-to-one correspondence
# between process classes and node classes
# Note: Improve this when issue #2475 is addressed
filter = {'like': '%'}
else:
warnings.warn("Process type '{}' does not correspond to a registered entry. "
'This risks queries to fail once the location of the process class changes. '
"Add an entry point for '{}' to remove this warning.".format(value, value),
AiidaEntryPointWarning)
filter = {'or': [
{'==': value},
{'like': escape_for_sql_like(get_query_string_from_process_type_string(value))},
]}
return filter
class QueryBuilder(object):
"""
The class to query the AiiDA database.
Usage::
from aiida.orm.querybuilder import QueryBuilder
qb = QueryBuilder()
# Querying nodes:
qb.append(Node)
# retrieving the results:
results = qb.all()
"""
# This tag defines how edges are tagged (labeled) by the QueryBuilder default
# namely tag of first entity + _EDGE_TAG_DELIM + tag of second entity
_EDGE_TAG_DELIM = '--'
_VALID_PROJECTION_KEYS = ('func', 'cast')
def __init__(self, backend=None, **kwargs):
"""
Instantiates a QueryBuilder instance.
Which backend is used decided here based on backend-settings (taken from the user profile).
This cannot be overriden so far by the user.
:param bool debug:
Turn on debug mode. This feature prints information on the screen about the stages
of the QueryBuilder. Does not affect results.
:param list path:
A list of the vertices to traverse. Leave empty if you plan on using the method
:func:`QueryBuilder.append`.
:param filters:
The filters to apply. You can specify the filters here, when appending to the query
using :func:`QueryBuilder.append` or even later using :func:`QueryBuilder.add_filter`.
Check latter gives API-details.
:param project:
The projections to apply. You can specify the projections here, when appending to the query
using :func:`QueryBuilder.append` or even later using :func:`QueryBuilder.add_projection`.
Latter gives you API-details.
:param int limit:
Limit the number of rows to this number. Check :func:`QueryBuilder.limit`
for more information.
:param int offset:
Set an offset for the results returned. Details in :func:`QueryBuilder.offset`.
:param order_by:
How to order the results. As the 2 above, can be set also at later stage,
check :func:`QueryBuilder.order_by` for more information.
"""
backend = backend or get_manager().get_backend()
self._impl = backend.query()
# A list storing the path being traversed by the query
self._path = []
# A list of unique aliases in same order as path
self._aliased_path = []
# A dictionary tag:alias of ormclass
# redundant but makes life easier
self.tag_to_alias_map = {}
# A dictionary tag: filter specification for this alias
self._filters = {}
# A dictionary tag: projections for this alias
self._projections = {}
# A dictionary for classes passed to the tag given to them
# Everything is specified with unique tags, which are strings.
# But somebody might not care about giving tags, so to do
# everything with classes one needs a map, that also defines classes
# as tags, to allow the following example:
# qb = QueryBuilder()
# qb.append(PwCalculation)
# qb.append(StructureData, with_outgoing=PwCalculation)
# The cls_to_tag_map in this case would be:
# {PwCalculation:'PwCalculation', StructureData:'StructureData'}
# Keep in mind that it needs to be checked (and this is done) whether the class
# is used twice. In that case, the user has to provide a tag!
self._cls_to_tag_map = {}
# Hashing the the internal queryhelp allows me to avoid to build a query again, if i have used
# it already.
# Example:
## User is building a query:
# qb = QueryBuilder().append(.....)
## User asks for the first results:
# qb.first()
## User asks for all results, of the same query:
# qb.all()
# In above example, I can reuse the query, and to track whether somethis was changed
# I record a hash:
self._hash = None
## The hash being None implies that the query will be build (Check the code in .get_query
# The user can inject a query, this keyword stores whether this was done.
# Check QueryBuilder.inject_query
self._injected = False
# Setting debug levels:
self.set_debug(kwargs.pop('debug', False))
# One can apply the path as a keyword. Allows for jsons to be given to the QueryBuilder.
path = kwargs.pop('path', [])
if not isinstance(path, (tuple, list)):
raise InputValidationError('Path needs to be a tuple or a list')
# If the user specified a path, I use the append method to analyze, see QueryBuilder.append
for path_spec in path:
if isinstance(path_spec, dict):
self.append(**path_spec)
# ~ except TypeError as e:
elif isinstance(path_spec, six.string_types):
# Maybe it is just a string,
# I assume user means the type
self.append(entity_type=path_spec)
else:
# Or a class, let's try
self.append(cls=path_spec)
# Projections. The user provides a dictionary, but the specific checks is
# left to QueryBuilder.add_project.
projection_dict = kwargs.pop('project', {})
if not isinstance(projection_dict, dict):
raise InputValidationError('You need to provide the projections as dictionary')
for key, val in projection_dict.items():
self.add_projection(key, val)
# For filters, I also expect a dictionary, and the checks are done lower.
filter_dict = kwargs.pop('filters', {})
if not isinstance(filter_dict, dict):
raise InputValidationError('You need to provide the filters as dictionary')
for key, val in filter_dict.items():
self.add_filter(key, val)
# The limit is caps the number of results returned, and can also be set with QueryBuilder.limit
self.limit(kwargs.pop('limit', None))
# The offset returns results after the offset
self.offset(kwargs.pop('offset', None))
# The user can also specify the order.
self._order_by = {}
order_spec = kwargs.pop('order_by', None)
if order_spec:
self.order_by(order_spec)
# I've gone through all the keywords, popping each item
# If kwargs is not empty, there is a problem:
if kwargs:
valid_keys = ('path', 'filters', 'project', 'limit', 'offset', 'order_by')
raise InputValidationError('Received additional keywords: {}'
'\nwhich I cannot process'
'\nValid keywords are: {}'
''.format(list(kwargs.keys()), valid_keys))
def __str__(self):
"""
When somebody hits: print(QueryBuilder) or print(str(QueryBuilder))
I want to print the SQL-query. Because it looks cool...
"""
from aiida.manage.configuration import get_config
config = get_config()
engine = config.current_profile.database_engine
if engine.startswith('mysql'):
from sqlalchemy.dialects import mysql as mydialect
elif engine.startswith('postgre'):
from sqlalchemy.dialects import postgresql as mydialect
else:
raise ConfigurationError('Unknown DB engine: {}'.format(engine))
que = self.get_query()
return str(que.statement.compile(compile_kwargs={'literal_binds': True}, dialect=mydialect.dialect()))
def _get_ormclass(self, cls, ormclass_type_string):
"""
Get ORM classifiers from either class(es) or ormclass_type_string(s).
:param cls: a class or tuple/set/list of classes that are either AiiDA ORM classes or backend ORM classes.
:param ormclass_type_string: type string for ORM class
:returns: the ORM class as well as a dictionary with additional classifier strings
Handles the case of lists as well.
"""
if cls is not None:
func = get_querybuilder_classifiers_from_cls
input_info = cls
elif ormclass_type_string is not None:
func = get_querybuilder_classifiers_from_type
input_info = ormclass_type_string
else:
raise RuntimeError('Neither cls nor ormclass_type_string specified')
if isinstance(input_info, (tuple, list, set)):
# Going through each element of the list/tuple/set:
ormclass = None
classifiers = []
for i, c in enumerate(input_info):
new_ormclass, new_classifiers = func(c, self._impl)
if i:
# This is not my first iteration!
# I check consistency with what was specified before
if new_ormclass != ormclass:
raise InputValidationError('Non-matching types have been passed as list/tuple/set.')
else:
# first iteration
ormclass = new_ormclass
classifiers.append(new_classifiers)
else:
ormclass, classifiers = func(input_info, self._impl)
return ormclass, classifiers
def _get_unique_tag(self, classifiers):
"""
Using the function get_tag_from_type, I get a tag.
I increment an index that is appended to that tag until I have an unused tag.
This function is called in :func:`QueryBuilder.append` when autotag is set to True.
:param dict classifiers:
Classifiers, containing the string that defines the type of the AiiDA ORM class.
For subclasses of Node, this is the Node._plugin_type_string, for other they are
as defined as returned by :func:`QueryBuilder._get_ormclass`.
Can also be a list of dictionaries, when multiple classes are passed to QueryBuilder.append
:returns: A tag as a string (it is a single string also when passing multiple classes).
"""
def get_tag_from_type(classifiers):
"""
Assign a tag to the given vertex of a path, based mainly on the type
* data.structure.StructureData -> StructureData
* data.structure.StructureData. -> StructureData
* calculation.job.quantumespresso.pw.PwCalculation. -. PwCalculation
* node.Node. -> Node
* Node -> Node
* computer -> computer
* etc.
:param str ormclass_type_string:
The string that defines the type of the AiiDA ORM class.
For subclasses of Node, this is the Node._plugin_type_string, for other they are
as defined as returned by :func:`QueryBuilder._get_ormclass`.
:returns: A tag, as a string.
"""
if isinstance(classifiers, list):
return '-'.join([t['ormclass_type_string'].rstrip('.').split('.')[-1] or 'node' for t in classifiers])
else:
return classifiers['ormclass_type_string'].rstrip('.').split('.')[-1] or 'node'
basetag = get_tag_from_type(classifiers)
tags_used = self.tag_to_alias_map.keys()
for i in range(1, 100):
tag = '{}_{}'.format(basetag, i)
if tag not in tags_used:
return tag
raise RuntimeError('Cannot find a tag after 100 tries')
def append(self,
cls=None,
entity_type=None,
tag=None,
filters=None,
project=None,
subclassing=True,
edge_tag=None,
edge_filters=None,
edge_project=None,
outerjoin=False,
**kwargs):
"""
Any iterative procedure to build the path for a graph query
needs to invoke this method to append to the path.
:param cls:
The Aiida-class (or backend-class) defining the appended vertice.
Also supports a tuple/list of classes. This results in an all instances of
this class being accepted in a query. However the classes have to have the same orm-class
for the joining to work. I.e. both have to subclasses of Node. Valid is::
cls=(StructureData, Dict)
This is invalid:
cls=(Group, Node)
:param entity_type: The node type of the class, if cls is not given. Also here, a tuple or list is accepted.
:type type: str
:param bool autotag: Whether to find automatically a unique tag. If this is set to True (default False),
:param str tag:
A unique tag. If none is given, I will create a unique tag myself.
:param filters:
Filters to apply for this vertex.
See :meth:`.add_filter`, the method invoked in the background, or usage examples for details.
:param project:
Projections to apply. See usage examples for details.
More information also in :meth:`.add_projection`.
:param bool subclassing:
Whether to include subclasses of the given class
(default **True**).
E.g. Specifying a ProcessNode as cls will include CalcJobNode, WorkChainNode, CalcFunctionNode, etc..
:param bool outerjoin:
If True, (default is False), will do a left outerjoin
instead of an inner join
:param str edge_tag:
The tag that the edge will get. If nothing is specified
(and there is a meaningful edge) the default is tag1--tag2 with tag1 being the entity joining
from and tag2 being the entity joining to (this entity).
:param str edge_filters:
The filters to apply on the edge. Also here, details in :meth:`.add_filter`.
:param str edge_project:
The project from the edges. API-details in :meth:`.add_projection`.
A small usage example how this can be invoked::
qb = QueryBuilder() # Instantiating empty querybuilder instance
qb.append(cls=StructureData) # First item is StructureData node
# The
# next node in the path is a PwCalculation, with
# the structure joined as an input
qb.append(
cls=PwCalculation,
with_incoming=StructureData
)
:return: self
:rtype: :class:`aiida.orm.QueryBuilder`
"""
# INPUT CHECKS ##########################
# This function can be called by users, so I am checking the
# input now.
# First of all, let's make sure the specified
# the class or the type (not both)
if cls and entity_type:
raise InputValidationError('You cannot specify both a class ({}) and a entity_type ({})'.format(cls, entity_type))
if not (cls or entity_type):
raise InputValidationError('You need to specify at least a class or a entity_type')
# Let's check if it is a valid class or type
if cls:
if isinstance(cls, (tuple, list, set)):
for c in cls:
if not inspect_isclass(c):
raise InputValidationError("{} was passed with kw 'cls', but is not a class".format(c))
else:
if not inspect_isclass(cls):
raise InputValidationError("{} was passed with kw 'cls', but is not a class".format(cls))
elif entity_type:
if isinstance(entity_type, (tuple, list, set)):
for t in entity_type:
if not isinstance(t, six.string_types):
raise InputValidationError('{} was passed as entity_type, but is not a string'.format(t))
else:
if not isinstance(entity_type, six.string_types):
raise InputValidationError('{} was passed as entity_type, but is not a string'.format(entity_type))
ormclass, classifiers = self._get_ormclass(cls, entity_type)
# TAG #################################
# Let's get a tag
if tag:
if self._EDGE_TAG_DELIM in tag:
raise InputValidationError('tag cannot contain {}\n'
'since this is used as a delimiter for links'
''.format(self._EDGE_TAG_DELIM))
tag = tag
if tag in self.tag_to_alias_map.keys():
raise InputValidationError('This tag ({}) is already in use'.format(tag))
else:
tag = self._get_unique_tag(classifiers)
# Checks complete
# This is where I start doing changes to self!
# Now, several things can go wrong along the way, so I need to split into
# atomic blocks that I can reverse if something goes wrong.
# TAG MAPPING #################################
# TODO check with duplicate classes
# Let's fill the cls_to_tag_map so that one can specify
# this vertice in a joining specification later
# First this only makes sense if a class was specified:
l_class_added_to_map = False
if cls:
# Note: tuples can be used as array keys, lists & sets can't
if isinstance(cls, (list, set)):
tag_key = tuple(cls)
else:
tag_key = cls
if tag_key in self._cls_to_tag_map.keys():
# In this case, this class already stands for another
# tag that was used before.
# This means that the first tag will be the correct
# one. This is dangerous and maybe should be avoided in
# the future
pass
else:
self._cls_to_tag_map[tag_key] = tag
l_class_added_to_map = True
# ALIASING ##############################
try:
self.tag_to_alias_map[tag] = aliased(ormclass)
except Exception as e:
if self._debug:
print('DEBUG: Exception caught in append, cleaning up')
print(' ', e)
if l_class_added_to_map:
self._cls_to_tag_map.pop(cls)
self.tag_to_alias_map.pop(tag, None)
raise
# FILTERS ######################################
try:
self._filters[tag] = {}
# So far, only Node and its subclasses need additional filters on column type
# (for other classes, the "classifi.
# This so far only is necessary for AiidaNodes not for groups.
# Now here there is the issue that for everything else,
# the query_type_string is either None (e.g. if Group was passed)
# or a list of None (if (Group, ) was passed.
# Here we have to only call the function _add_type_filter essentially if it makes sense to
# For now that is only nodes, and it is hardcoded. In the future (e.g. we subclass group)
# this has to be added
if ormclass == self._impl.Node:
self._add_type_filter(tag, classifiers, subclassing)
self._add_process_type_filter(tag, classifiers, subclassing)
# The order has to be first _add_type_filter and then add_filter.
# If the user adds a query on the type column, it overwrites what I did
# if the user specified a filter, add it:
if filters is not None:
self.add_filter(tag, filters)
except Exception as e:
if self._debug:
print('DEBUG: Exception caught in append (part filters), cleaning up')
print(' ', e)
if l_class_added_to_map:
self._cls_to_tag_map.pop(cls)
self.tag_to_alias_map.pop(tag)
self._filters.pop(tag)
raise
# PROJECTIONS ##############################
try:
self._projections[tag] = []
if project is not None:
self.add_projection(tag, project)
except Exception as e:
if self._debug:
print('DEBUG: Exception caught in append (part projections), cleaning up')
print(' ', e)
if l_class_added_to_map:
self._cls_to_tag_map.pop(cls)
self.tag_to_alias_map.pop(tag, None)
self._filters.pop(tag)
self._projections.pop(tag)
raise e
# JOINING #####################################
try:
# Get the functions that are implemented:
spec_to_function_map = []
for secondary_dict in self._get_function_map().values():
for key in secondary_dict.keys():
if key not in spec_to_function_map:
spec_to_function_map.append(key)
joining_keyword = kwargs.pop('joining_keyword', None)
joining_value = kwargs.pop('joining_value', None)
for key, val in kwargs.items():
if key not in spec_to_function_map:
raise InputValidationError(
'{} is not a valid keyword '
'for joining specification\n'
'Valid keywords are: '
'{}'.format(key,
spec_to_function_map + ['cls', 'type', 'tag', 'autotag', 'filters', 'project']))
elif joining_keyword:
raise InputValidationError('You already specified joining specification {}\n'
'But you now also want to specify {}'
''.format(joining_keyword, key))
else:
joining_keyword = key
if joining_keyword == 'direction':
if not isinstance(val, int):
raise InputValidationError('direction=n expects n to be an integer')
try:
if val < 0:
joining_keyword = 'with_outgoing'
elif val > 0:
joining_keyword = 'with_incoming'
else:
raise InputValidationError('direction=0 is not valid')
joining_value = self._path[-abs(val)]['tag']
except IndexError as exc:
raise InputValidationError('You have specified a non-existent entity with\n'
'direction={}\n'
'{}\n'.format(joining_value, exc))
else:
joining_value = self._get_tag_from_specification(val)
# the default is that this vertice is 'with_incoming' as the previous one
if joining_keyword is None and len(self._path) > 0:
joining_keyword = 'with_incoming'
joining_value = self._path[-1]['tag']
except Exception as e:
if self._debug:
print('DEBUG: Exception caught in append (part joining), cleaning up')
print(' ', e)
if l_class_added_to_map:
self._cls_to_tag_map.pop(cls)
self.tag_to_alias_map.pop(tag, None)
self._filters.pop(tag)
self._projections.pop(tag)
# There's not more to clean up here!
raise e
# EDGES #################################
if len(self._path) > 0:
try:
if self._debug:
print('DEBUG: Choosing an edge_tag')
if edge_tag is None:
edge_destination_tag = self._get_tag_from_specification(joining_value)
edge_tag = edge_destination_tag + self._EDGE_TAG_DELIM + tag
else:
if edge_tag in self.tag_to_alias_map.keys():
raise InputValidationError('The tag {} is already in use'.format(edge_tag))
if self._debug:
print('I have chosen', edge_tag)
# My edge is None for now, since this is created on the FLY,
# the _tag_to_alias_map will be updated later (in _build)
self.tag_to_alias_map[edge_tag] = None
# Filters on links:
# Beware, I alway add this entry now, but filtering here might be
# non-sensical, since this ONLY works for m2m relationship where
# I go through a different table
self._filters[edge_tag] = {}
if edge_filters is not None:
self.add_filter(edge_tag, edge_filters)
# Projections on links
self._projections[edge_tag] = []
if edge_project is not None:
self.add_projection(edge_tag, edge_project)
except Exception as e:
if self._debug:
print('DEBUG: Exception caught in append (part joining), cleaning up')
import traceback
print(traceback.format_exc())
if l_class_added_to_map:
self._cls_to_tag_map.pop(cls)
self.tag_to_alias_map.pop(tag, None)
self._filters.pop(tag)
self._projections.pop(tag)
if edge_tag is not None:
self.tag_to_alias_map.pop(edge_tag, None)
self._filters.pop(edge_tag, None)
self._projections.pop(edge_tag, None)
# There's not more to clean up here!
raise e
# EXTENDING THE PATH #################################
# Note: 'type' being a list is a relict of an earlier implementation
# Could simply pass all classifiers here.
if isinstance(classifiers, list):
path_type = [c['ormclass_type_string'] for c in classifiers]
else:
path_type = classifiers['ormclass_type_string']
self._path.append(
dict(
entity_type=path_type,
tag=tag,
joining_keyword=joining_keyword,
joining_value=joining_value,
outerjoin=outerjoin,
edge_tag=edge_tag))
return self
def order_by(self, order_by):
"""
Set the entity to order by
:param order_by:
This is a list of items, where each item is a dictionary specifies
what to sort for an entity
In each dictionary in that list, keys represent valid tags of
entities (tables), and values are list of columns.
Usage::
#Sorting by id (ascending):
qb = QueryBuilder()
qb.append(Node, tag='node')
qb.order_by({'node':['id']})
# or
#Sorting by id (ascending):
qb = QueryBuilder()
qb.append(Node, tag='node')
qb.order_by({'node':[{'id':{'order':'asc'}}]})
# for descending order:
qb = QueryBuilder()
qb.append(Node, tag='node')
qb.order_by({'node':[{'id':{'order':'desc'}}]})
# or (shorter)
qb = QueryBuilder()
qb.append(Node, tag='node')
qb.order_by({'node':[{'id':'desc'}]})
"""
self._order_by = []
allowed_keys = ('cast', 'order')
possible_orders = ('asc', 'desc')
if not isinstance(order_by, (list, tuple)):
order_by = [order_by]
for order_spec in order_by:
if not isinstance(order_spec, dict):
raise InputValidationError('Invalid input for order_by statement: {}\n'
'I am expecting a dictionary ORMClass,'
'[columns to sort]'
''.format(order_spec))
_order_spec = {}
for tagspec, items_to_order_by in order_spec.items():
if not isinstance(items_to_order_by, (tuple, list)):
items_to_order_by = [items_to_order_by]
tag = self._get_tag_from_specification(tagspec)
_order_spec[tag] = []
for item_to_order_by in items_to_order_by:
if isinstance(item_to_order_by, six.string_types):
item_to_order_by = {item_to_order_by: {}}
elif isinstance(item_to_order_by, dict):
pass
else:
raise InputValidationError('Cannot deal with input to order_by {}\n'
'of type{}'
'\n'.format(item_to_order_by, type(item_to_order_by)))
for entityname, orderspec in item_to_order_by.items():
# if somebody specifies eg {'node':{'id':'asc'}}
# tranform to {'node':{'id':{'order':'asc'}}}
if isinstance(orderspec, six.string_types):
this_order_spec = {'order': orderspec}
elif isinstance(orderspec, dict):
this_order_spec = orderspec
else:
raise InputValidationError('I was expecting a string or a dictionary\n'
'You provided {} {}\n'
''.format(type(orderspec), orderspec))
for key in this_order_spec.keys():
if key not in allowed_keys:
raise InputValidationError('The allowed keys for an order specification\n'
'are {}\n'
'{} is not valid\n'
''.format(', '.join(allowed_keys), key))
this_order_spec['order'] = this_order_spec.get('order', 'asc')
if this_order_spec['order'] not in possible_orders:
raise InputValidationError('You gave {} as an order parameters,\n'
'but it is not a valid order parameter\n'
'Valid orders are: {}\n'
''.format(this_order_spec['order'], possible_orders))
item_to_order_by[entityname] = this_order_spec
_order_spec[tag].append(item_to_order_by)
self._order_by.append(_order_spec)
return self
def add_filter(self, tagspec, filter_spec):
"""
Adding a filter to my filters.
:param tagspec: The tag, which has to exist already as a key in self._filters
:param filter_spec: The specifications for the filter, has to be a dictionary
Usage::
qb = QueryBuilder() # Instantiating the QueryBuilder instance
qb.append(Node, tag='node') # Appending a Node
#let's put some filters:
qb.add_filter('node',{'id':{'>':12}})
# 2 filters together:
qb.add_filter('node',{'label':'foo', 'uuid':{'like':'ab%'}})
# Now I am overriding the first filter I set:
qb.add_filter('node',{'id':13})
"""
filters = self._process_filters(filter_spec)
tag = self._get_tag_from_specification(tagspec)
self._filters[tag].update(filters)
def _process_filters(self, filters):
if not isinstance(filters, dict):
raise InputValidationError('Filters have to be passed as dictionaries')
for key, value in filters.items():
if isinstance(value, entities.Entity):
# Convert to be the id of the joined entity because we can't query
# for the object instance directly
filters.pop(key)
filters['{}_id'.format(key)] = value.id
return filters
def _add_type_filter(self, tagspec, classifiers, subclassing):
"""
Add a filter based on type.