-
-
Notifications
You must be signed in to change notification settings - Fork 323
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Introduce function API #1022
Introduce function API #1022
Changes from 98 commits
f714909
6bdb935
90710c3
b51cf6b
1819c0b
639c14b
5025f87
906cbd1
efd3dc2
aa9239e
828814a
58a9432
7705bae
7379d8c
ee1f01a
c18e0fe
74c6626
78bf19b
311456e
201837f
54e143a
086d1d3
6424c1f
b1910b7
30ea5c4
db73bda
8d16a0f
79172bc
28eb1b3
485184e
02eb143
e39c3a9
748bc4b
0250369
be0e144
75ad723
7868bc2
fc9cf88
57d23fd
7908e0d
c2aca6d
0d5a4c8
3128800
f5f8edc
7033be3
f8ac768
f84dbab
ed7998d
405a6ea
80055ab
8f05a9f
b2e78e8
2f0b63c
5001bc9
2e0918b
793adf8
190c09d
541a6cf
a2d2e63
45e691d
b1298c4
0dbcfb5
f2c04d3
6cbc5e6
0885923
cf85466
1a48f03
cd37250
cf212b7
cf3313f
84148b9
cf1b9bb
5bd62c5
169c8f4
b7c72c2
1cb877c
689fa15
05b3cdc
5325098
2e56359
e6891d8
fe3b7f5
446fdf7
7b737fd
8b64508
ce7cc7b
4d2e5ff
89ddf5c
cbf9968
b7b6fce
27c246e
5fec9a0
31c5755
fe799ac
336943a
fdb9129
2e504d9
18992c1
d547e4f
6e96593
a0eb951
ff8fd06
40b7dd5
a234632
fdbcaf7
8127ccf
1c7b439
08e5dfc
3173b22
306e1d7
06cea56
9b552c8
fd474b6
ea3f200
2564c0b
0053051
fc0f8f6
7de8eee
70ebae2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,261 @@ | ||
""" | ||
This namespace defines the DbFunction abstract class and its subclasses. These subclasses | ||
represent functions that have identifiers, display names and hints, and their instances | ||
hold parameters. Each DbFunction subclass defines how its instance can be converted into an | ||
SQLAlchemy expression. | ||
|
||
Hints hold information about what kind of input the function might expect and what output | ||
can be expected from it. This is used to provide interface information without constraining its | ||
user. | ||
|
||
These classes might be used, for example, to define a filter for an SQL query, or to | ||
access hints on what composition of functions and parameters should be valid. | ||
""" | ||
|
||
from abc import ABC, abstractmethod | ||
|
||
from sqlalchemy import column, not_, and_, or_, func, literal | ||
from db.types.uri import URIFunction | ||
|
||
from db.functions import hints | ||
|
||
import importlib | ||
import inspect | ||
|
||
|
||
class DbFunction(ABC): | ||
id = None | ||
name = None | ||
hints = None | ||
|
||
def __init__(self, parameters): | ||
if self.id is None: | ||
raise ValueError('DbFunction subclasses must define an ID.') | ||
if self.name is None: | ||
raise ValueError('DbFunction subclasses must define a name.') | ||
self.parameters = parameters | ||
|
||
@property | ||
def referenced_columns(self): | ||
"""Walks the expression tree, collecting referenced columns. | ||
Useful when checking if all referenced columns are present in the queried relation.""" | ||
columns = set([]) | ||
for parameter in self.parameters: | ||
if isinstance(parameter, ColumnReference): | ||
columns.add(parameter.column) | ||
elif isinstance(parameter, DbFunction): | ||
columns.update(parameter.referenced_columns) | ||
return columns | ||
|
||
@staticmethod | ||
@abstractmethod | ||
def to_sa_expression(): | ||
return None | ||
|
||
|
||
class Literal(DbFunction): | ||
id = 'literal' | ||
name = 'Literal' | ||
hints = tuple([ | ||
hints.parameter_count(1), | ||
hints.parameter(1, hints.literal), | ||
]) | ||
|
||
@staticmethod | ||
def to_sa_expression(p): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could you use a more verbose variable than There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also applies to other instances. |
||
return literal(p) | ||
|
||
|
||
class ColumnReference(DbFunction): | ||
id = 'column_reference' | ||
name = 'Column Reference' | ||
hints = tuple([ | ||
hints.parameter_count(1), | ||
hints.parameter(1, hints.column), | ||
]) | ||
|
||
@property | ||
def column(self): | ||
return self.parameters[0] | ||
|
||
@staticmethod | ||
def to_sa_expression(p): | ||
return column(p) | ||
|
||
|
||
class List(DbFunction): | ||
id = 'list' | ||
name = 'List' | ||
|
||
@staticmethod | ||
def to_sa_expression(*ps): | ||
return list(ps) | ||
|
||
|
||
class Empty(DbFunction): | ||
id = 'empty' | ||
name = 'Empty' | ||
hints = tuple([ | ||
hints.returns(hints.boolean), | ||
hints.parameter_count(1), | ||
]) | ||
|
||
@staticmethod | ||
def to_sa_expression(p): | ||
return p.is_(None) | ||
|
||
|
||
class Not(DbFunction): | ||
id = 'not' | ||
name = 'Not' | ||
hints = tuple([ | ||
hints.returns(hints.boolean), | ||
hints.parameter_count(1), | ||
]) | ||
|
||
@staticmethod | ||
def to_sa_expression(*p): | ||
return not_(*p) | ||
|
||
|
||
class Equal(DbFunction): | ||
id = 'equal' | ||
name = 'Equal' | ||
hints = tuple([ | ||
hints.returns(hints.boolean), | ||
hints.parameter_count(2), | ||
]) | ||
|
||
@staticmethod | ||
def to_sa_expression(p1, p2): | ||
return p1 == p2 | ||
|
||
|
||
class Greater(DbFunction): | ||
id = 'greater' | ||
name = 'Greater' | ||
hints = tuple([ | ||
hints.returns(hints.boolean), | ||
hints.parameter_count(2), | ||
hints.all_parameters(hints.comparable), | ||
]) | ||
|
||
@staticmethod | ||
def to_sa_expression(p1, p2): | ||
return p1 > p2 | ||
|
||
|
||
class Lesser(DbFunction): | ||
id = 'lesser' | ||
name = 'Lesser' | ||
hints = tuple([ | ||
hints.returns(hints.boolean), | ||
hints.parameter_count(2), | ||
hints.all_parameters(hints.comparable), | ||
]) | ||
|
||
@staticmethod | ||
def to_sa_expression(p1, p2): | ||
return p1 < p2 | ||
|
||
|
||
class In(DbFunction): | ||
id = 'in' | ||
name = 'In' | ||
hints = tuple([ | ||
hints.returns(hints.boolean), | ||
hints.parameter_count(2), | ||
hints.parameter(2, hints.array), | ||
]) | ||
|
||
@staticmethod | ||
def to_sa_expression(p1, p2): | ||
return p1.in_(p2) | ||
|
||
|
||
class And(DbFunction): | ||
id = 'and' | ||
name = 'And' | ||
hints = tuple([ | ||
hints.returns(hints.boolean), | ||
]) | ||
|
||
@staticmethod | ||
def to_sa_expression(*ps): | ||
return and_(*ps) | ||
|
||
|
||
class Or(DbFunction): | ||
id = 'or' | ||
name = 'Or' | ||
hints = tuple([ | ||
hints.returns(hints.boolean), | ||
]) | ||
|
||
@staticmethod | ||
def to_sa_expression(*ps): | ||
return or_(*ps) | ||
|
||
|
||
class StartsWith(DbFunction): | ||
id = 'starts_with' | ||
name = 'Starts With' | ||
hints = tuple([ | ||
hints.returns(hints.boolean), | ||
hints.parameter_count(2), | ||
hints.all_parameters(hints.string_like), | ||
]) | ||
|
||
@staticmethod | ||
def to_sa_expression(p1, p2): | ||
return p1.like(f'{p2}%') | ||
|
||
|
||
class ToLowercase(DbFunction): | ||
id = 'to_lowercase' | ||
name = 'To Lowercase' | ||
hints = tuple([ | ||
hints.parameter_count(1), | ||
hints.all_parameters(hints.string_like), | ||
]) | ||
|
||
@staticmethod | ||
def to_sa_expression(p1): | ||
return func.lower(p1) | ||
|
||
|
||
class ExtractURIAuthority(DbFunction): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since this depends on our custom URI type, I would not put this in the general functions namespace. I'd store it with the URI type instead. Keep in mind that our custom types may not be installed on all Mathesar deployments. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should apply to any functions that depend on our custom types. |
||
id = 'extract_uri_authority' | ||
name = 'Extract URI Authority' | ||
hints = tuple([ | ||
hints.parameter_count(1), | ||
hints.parameter(1, hints.uri), | ||
]) | ||
|
||
@staticmethod | ||
def to_sa_expression(p1): | ||
return func.getattr(URIFunction.AUTHORITY)(p1) | ||
|
||
|
||
def _get_defining_module_members_that_satisfy(predicate): | ||
# NOTE: the value returned by globals() (when it's called within a function) is set when the | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could you explain in a docstring what this function does? |
||
# function is defined and does not change depending on where the function is called from. | ||
# See https://docs.python.org/3/library/functions.html#globals | ||
# If we wanted to move this function into another namespace, we would have to additionally | ||
# pass it this namespace's globals(). | ||
defining_module_name = globals()['__name__'] | ||
defining_module = importlib.import_module(defining_module_name) | ||
all_members_in_defining_module = inspect.getmembers(defining_module) | ||
return tuple( | ||
member | ||
for _, member in all_members_in_defining_module | ||
if predicate(member) | ||
) | ||
|
||
|
||
def _is_concrete_db_function_subclass(member): | ||
return inspect.isclass(member) and member != DbFunction and issubclass(member, DbFunction) | ||
|
||
|
||
# Enumeration of supported DbFunction subclasses; needed when parsing. | ||
supported_db_functions = _get_defining_module_members_that_satisfy(_is_concrete_db_function_subclass) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wacky idea: If you use the ToLowercase = type(
'ToLowercase',
(DbFunction,),
dict(
id='to_lowercase',
name='To Lowercase',
hints=tuple([hints.parameter_count(1), hints.all_parameters(hints.string_like)]),
to_sa_expression=staticmethod(lambda p1: func.lower(p1))
)
) You'd set up a factory function (maybe a factory method of the parent class, actually; matter of style) that takes the right arguments, and loop over a list of dicts (or whatever) of args to create the subclasses. Advantages:
Disadvantages:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I like this. If generating a dict or list of This makes me wonder how I'll get There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You could generate them in a dict, then (in the same loop) add them to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for the tips. Useful. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
class BadDbFunctionFormat(Exception): | ||
pass | ||
|
||
|
||
class UnknownDbFunctionId(BadDbFunctionFormat): | ||
pass | ||
|
||
|
||
class ReferencedColumnsDontExist(BadDbFunctionFormat): | ||
pass |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
from frozendict import frozendict | ||
|
||
|
||
def _make_hint(id, **rest): | ||
return frozendict({"id": id, **rest}) | ||
|
||
|
||
def parameter_count(count): | ||
return _make_hint("parameter_count", count=count) | ||
|
||
|
||
def parameter(index, *hints): | ||
return _make_hint("parameter", index=index, hints=hints) | ||
|
||
|
||
def all_parameters(*hints): | ||
return _make_hint("all_parameters", hints=hints) | ||
|
||
|
||
def returns(*hints): | ||
return _make_hint("returns", hints=hints) | ||
|
||
|
||
boolean = _make_hint("boolean") | ||
|
||
|
||
comparable = _make_hint("comparable") | ||
|
||
|
||
column = _make_hint("column") | ||
|
||
|
||
array = _make_hint("array") | ||
|
||
|
||
string_like = _make_hint("string_like") | ||
|
||
|
||
uri = _make_hint("uri") | ||
|
||
|
||
literal = _make_hint("literal") |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
from db.functions.base import DbFunction | ||
from db.functions.exceptions import ReferencedColumnsDontExist | ||
from db.functions.operations.deserialize import get_db_function_from_ma_function_spec | ||
|
||
|
||
def apply_ma_function_spec_as_filter(relation, ma_function_spec): | ||
db_function = get_db_function_from_ma_function_spec(ma_function_spec) | ||
return apply_db_function_as_filter(relation, db_function) | ||
|
||
|
||
def apply_db_function_as_filter(relation, db_function): | ||
_assert_that_all_referenced_columns_exist(relation, db_function) | ||
sa_expression = _db_function_to_sa_expression(db_function) | ||
relation = relation.filter(sa_expression) | ||
return relation | ||
|
||
|
||
def _assert_that_all_referenced_columns_exist(relation, db_function): | ||
columns_that_exist = _get_columns_that_exist(relation) | ||
referenced_columns = db_function.referenced_columns | ||
referenced_columns_that_dont_exist = \ | ||
set.difference(referenced_columns, columns_that_exist) | ||
if len(referenced_columns_that_dont_exist) > 0: | ||
raise ReferencedColumnsDontExist( | ||
"These referenced columns don't exist on the relevant relation: " | ||
+ f"{referenced_columns_that_dont_exist}" | ||
) | ||
|
||
|
||
def _get_columns_that_exist(relation): | ||
columns = relation.selected_columns | ||
return set(column.name for column in columns) | ||
|
||
|
||
def _db_function_to_sa_expression(db_function): | ||
""" | ||
Everything is considered to be either a DbFunction subclass or a literal. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What is "everything" here? |
||
""" | ||
if isinstance(db_function, DbFunction): | ||
raw_parameters = db_function.parameters | ||
parameters = [ | ||
_db_function_to_sa_expression(raw_parameter) | ||
for raw_parameter in raw_parameters | ||
] | ||
db_function_subclass = type(db_function) | ||
return db_function_subclass.to_sa_expression(*parameters) | ||
else: | ||
return db_function |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please change this to
DBFunction
, we agreed to use uppercase for acronyms in the backend code here.