diff --git a/backend/api/controller/datasets.py b/backend/api/controller/datasets.py index 3a2b00c9..6bc955e8 100644 --- a/backend/api/controller/datasets.py +++ b/backend/api/controller/datasets.py @@ -624,6 +624,13 @@ async def query_large_dataset( #### Domain and dataset + The domain and dataset names must adhere to the following conditions: + + - Only alphanumeric and underscore `_` characters allowed + - Start with an alphabetic character + + The domain must also be lowercase only. + ### Outputs Asynchronous Job ID that can be used to track the progress of the query. diff --git a/backend/rapid/items/query.py b/backend/rapid/items/query.py index 5aa12c30..93b80697 100644 --- a/backend/rapid/items/query.py +++ b/backend/rapid/items/query.py @@ -1,17 +1,143 @@ from strenum import StrEnum -from typing import Optional, List -from pydantic import BaseModel, ConfigDict +from typing import Optional, List, Union +from pydantic import BaseModel, ConfigDict, field_validator + + +def _validate_column_name(column: str, allow_empty: bool = False, allow_functions: bool = False) -> str: + """Shared column name validation to prevent SQL injection""" + if not column: + if allow_empty: + return column + raise ValueError("Column name cannot be empty") + + allowed = ('_', '.') + + # Add function characters if needed + if allow_functions: + allowed = allowed + ('(', ')', '*', ',') + + if not all(c.isalnum() or c in allowed for c in column): + raise ValueError(f"Invalid column name: {column}") + + return column class SortDirection(StrEnum): ASC = "ASC" DESC = "DESC" +class LogicOperator(StrEnum): + AND = "AND" + OR = "OR" + +class FilterOperator(StrEnum): + EQUALS = "=" + NOT_EQUALS = "!=" + GREATER_THAN = ">" + GREATER_THAN_OR_EQUAL = ">=" + LESS_THAN = "<" + LESS_THAN_OR_EQUAL = "<=" + LIKE = "LIKE" + NOT_LIKE = "NOT LIKE" + IN = "IN" + NOT_IN = "NOT IN" + IS_NULL = "IS NULL" + IS_NOT_NULL = "IS NOT NULL" + +class FilterCondition(BaseModel): + column: str + operator: FilterOperator + value: Optional[Union[str, int, float, bool, List[Union[str, int, float, bool]]]] = None + + @field_validator('column') + @classmethod + def validate_column_name(cls, v: str) -> str: + return _validate_column_name(v, allow_empty=False, allow_functions=True) + + @field_validator('value') + @classmethod + def validate_value_required(cls, v, info): + operator = info.data.get('operator') + if operator in [FilterOperator.IS_NULL, FilterOperator.IS_NOT_NULL]: + if v is not None: + raise ValueError(f"Operator {operator} should not have a value") + else: + if v is None: + raise ValueError(f"Operator {operator} requires a value") + return v + + def to_sql(self) -> str: + """Convert filter condition to SQL string""" + operator_str = self.operator.value + + # Handle NULL checks + if self.operator in [FilterOperator.IS_NULL, FilterOperator.IS_NOT_NULL]: + return f"{self.column} {operator_str}" + + # Handle IN and NOT IN operators + if self.operator in [FilterOperator.IN, FilterOperator.NOT_IN]: + if not isinstance(self.value, list): + raise ValueError(f"Operator {self.operator} requires a list value") + escaped_values = [self._escape_value(v) for v in self.value] + values_str = ", ".join(escaped_values) + return f"{self.column} {operator_str} ({values_str})" + + # Handle standard comparison operators + escaped_value = self._escape_value(self.value) + return f"{self.column} {operator_str} {escaped_value}" + + @staticmethod + def _escape_value(value: Union[str, int, float, bool]) -> str: + """Escape a value for SQL usage""" + if isinstance(value, bool): + return "TRUE" if value else "FALSE" + elif isinstance(value, (int, float)): + return str(value) + elif isinstance(value, str): + # Escape single quotes by doubling them + escaped = value.replace("'", "''") + return f"'{escaped}'" + else: + raise ValueError(f"Unsupported value type: {type(value)}") + +class FilterGroup(BaseModel): + """ + A group of filter conditions combined with AND or OR logic. + Supports nesting for complex boolean expressions. + """ + logic_operator: Optional[LogicOperator] = None + conditions: Optional[List[FilterCondition]] = None + groups: Optional[List['FilterGroup']] = None + + def to_sql(self) -> str: + """Convert filter group to SQL string""" + parts = [] + + if self.conditions: + parts.extend([condition.to_sql() for condition in self.conditions]) + + if self.groups: + parts.extend([f"({group.to_sql()})" for group in self.groups]) + + if not parts: + return "" + + if len(parts) == 1: + return parts[0] + + if not self.logic_operator: + raise ValueError("logic_operator is required when you have multiple conditions or groups") + + return f" {self.logic_operator.value} ".join(parts) class QueryOrderBy(BaseModel): column: str direction: SortDirection = SortDirection("ASC") + @field_validator('column') + @classmethod + def validate_column_name(cls, v: str) -> str: + return _validate_column_name(v, allow_empty=True, allow_functions=False) class Query(BaseModel): """ @@ -20,46 +146,54 @@ class Query(BaseModel): on how to write a valid query. Example: - A query can created by setting the values literally into the class like:: - - query = Query( - select_columns=["column_a", "column_b"], - limit="5" - ) - - The alternative is you can create a schema directly from a Python dictionary:: - - query = Query( - **{ - "select_columns": ["column_a", "column_b"], - "limit": "5" - } - ) + query = Query( + **{ + "select_columns": ["column_a", "column_b"], + "filter": { + "logic_operator": "AND", + "conditions": [ + {"column": "column_one", "operator": ">", "value": 10} + ] + }, + "limit": 5 + } + ) """ model_config = ConfigDict(extra='forbid') select_columns: Optional[List[str]] = None - filter: Optional[str] = None + filter: Optional[FilterGroup] = None group_by_columns: Optional[List[str]] = None - aggregation_conditions: Optional[str] = None + aggregation_conditions: Optional[FilterGroup] = None order_by_columns: Optional[List[QueryOrderBy]] = None limit: Optional[int] = None + @field_validator('select_columns') + @classmethod + def validate_select_columns(cls, v): + if v is not None: + for col in v: + _validate_column_name(col, allow_empty=True, allow_functions=True) + return v + + @field_validator('group_by_columns') + @classmethod + def validate_group_by_columns(cls, v): + if v is not None: + for col in v: + _validate_column_name(col, allow_empty=True, allow_functions=False) + return v + def to_sql(self, table_name: str) -> str: - select = ( - f"SELECT {self._generate_select_columns()} FROM {table_name}" # nosec: B608 - ) + select = f"SELECT {self._generate_select_columns()} FROM {table_name}" # nosec: B608 filter = self._generate_filter() group_by = self._generate_group_by_columns() aggregation_conditions = self._generate_aggregation_conditions() order_by = self._generate_order_by_columns() limit = self._generate_limit() - constructed_sql = ( - f"{select}{filter}{group_by}{aggregation_conditions}{order_by}{limit}" - ) - return constructed_sql + return f"{select}{filter}{group_by}{aggregation_conditions}{order_by}{limit}" def _generate_select_columns(self): columns = self._generate_columns(self.select_columns, "") @@ -69,20 +203,19 @@ def _generate_select_columns(self): return columns def _generate_filter(self): - if self.filter is not None and self.filter != "": - return f" WHERE {self.filter}" - return "" + if self.filter is None: + return "" + sql = self.filter.to_sql() + return f" WHERE {sql}" if sql else "" def _generate_group_by_columns(self): return self._generate_columns(self.group_by_columns, " GROUP BY ") def _generate_aggregation_conditions(self): - if ( - self.aggregation_conditions is not None - and self.aggregation_conditions != "" - ): - return f" HAVING {self.aggregation_conditions}" - return "" + if self.aggregation_conditions is None: + return "" + sql = self.aggregation_conditions.to_sql() + return f" HAVING {sql}" if sql else "" def _generate_order_by_columns(self): if self.order_by_columns is None or len(self.order_by_columns) == 0: diff --git a/backend/test/api/controller/test_datasets.py b/backend/test/api/controller/test_datasets.py index cf4a5ea1..2ee2d173 100644 --- a/backend/test/api/controller/test_datasets.py +++ b/backend/test/api/controller/test_datasets.py @@ -870,8 +870,8 @@ def test_calls_service_with_sql_query_when_empty_json_values_provided( ): request_json = { "select_columns": ["column1"], - "filter": "", - "aggregation_conditions": "", + "filter": None, + "aggregation_conditions": None, "limit": "10", } @@ -885,8 +885,8 @@ def test_calls_service_with_sql_query_when_empty_json_values_provided( DatasetMetadata("raw", "mydomain", "mydataset", 1), Query( select_columns=["column1"], - filter="", - aggregation_conditions="", + filter=None, + aggregation_conditions=None, limit="10", ), ) @@ -1111,8 +1111,8 @@ def test_calls_service_with_sql_query_when_empty_json_values_provided( ): request_json = { "select_columns": ["column1"], - "filter": "", - "aggregation_conditions": "", + "filter": None, + "aggregation_conditions": None, "limit": "10", } @@ -1137,8 +1137,8 @@ def test_calls_service_with_sql_query_when_empty_json_values_provided( ), Query( select_columns=["column1"], - filter="", - aggregation_conditions="", + filter=None, + aggregation_conditions=None, limit="10", ), ) diff --git a/backend/test/api/domain/test_query.py b/backend/test/api/domain/test_query.py index 68233c07..9ecb7c59 100644 --- a/backend/test/api/domain/test_query.py +++ b/backend/test/api/domain/test_query.py @@ -1,6 +1,6 @@ import pytest -from rapid.items.query import Query, QueryOrderBy +from rapid.items.query import Query, QueryOrderBy, FilterCondition, FilterGroup class TestQuery: @@ -21,7 +21,24 @@ def test_only_select_columns_provided(self): ) def test_only_filters_provided(self): - sql_query = Query(filter="col1 > 16") + sql_query = Query( + filter=FilterGroup( + logic_operator="AND", + conditions=[FilterCondition(column="col1", operator=">", value=16)] + ) + ) + assert ( + sql_query.to_sql("test_domain") + == "SELECT * FROM test_domain WHERE col1 > 16" + ) + + def test_single_filter_without_logic_operator(self): + """Test single filter condition without specifying logic_operator""" + sql_query = Query( + filter=FilterGroup( + conditions=[FilterCondition(column="col1", operator=">", value=16)] + ) + ) assert ( sql_query.to_sql("test_domain") == "SELECT * FROM test_domain WHERE col1 > 16" @@ -52,94 +69,247 @@ def test_only_limit_provided(self): sql_query = Query(limit="10") assert sql_query.to_sql("test_domain") == "SELECT * FROM test_domain LIMIT 10" - @pytest.mark.parametrize( - "select_columns,filter,group_by_columns,aggregation_conditions,order_by_columns,limit,expected_sql", - [ - ( - ["col1", "col2", "col3"], # noqa: E126 - "col2 = 123", - ["col4", "col5"], - "col4 in ('some value', 'another value')", - [ - QueryOrderBy(column="col1"), - QueryOrderBy(column="col2", direction="DESC"), - ], - 10, - "SELECT col1,col2,col3 FROM test_domain WHERE col2 = 123 GROUP BY col4,col5 HAVING col4 in ('some value', 'another value') ORDER BY col1 ASC,col2 DESC LIMIT 10", - ), - ( - [], # noqa: E126 - "col2 = 123", - ["col4", "col5"], - None, - [], - 10, - "SELECT * FROM test_domain WHERE col2 = 123 GROUP BY col4,col5 LIMIT 10", - ), - ( - [], # noqa: E126 - "col2 = 123", - ["col4", "col5"], - "", - [], - 10, - "SELECT * FROM test_domain WHERE col2 = 123 GROUP BY col4,col5 LIMIT 10", - ), - ( - ["avg(col2)"], # noqa: E126 - "col2 >= 100", - ["col4", "col5"], - None, - [], - 50, - "SELECT avg(col2) FROM test_domain WHERE col2 >= 100 GROUP BY col4,col5 LIMIT 50", - ), - ( - ["avg(col2)"], # noqa: E126 - "col2 >= 100", - ["col4", "col5"], - "", - [], - 50, - "SELECT avg(col2) FROM test_domain WHERE col2 >= 100 GROUP BY col4,col5 LIMIT 50", - ), - ( - ["col1", "avg(col2)", ""], # noqa: E126 - None, - ["col1"], - "", - [], - 675, - "SELECT col1,avg(col2) FROM test_domain GROUP BY col1 LIMIT 675", + def test_complex_query_with_all_clauses(self): + sql_query = Query( + select_columns=["col1", "col2", "col3"], + filter=FilterGroup( + logic_operator="AND", + conditions=[FilterCondition(column="col2", operator="=", value=123)] ), - ( - ["col1", "avg(col2)", ""], # noqa: E126 - "", - ["col1"], - "", - [], - None, - "SELECT col1,avg(col2) FROM test_domain GROUP BY col1", + group_by_columns=["col4", "col5"], + aggregation_conditions=FilterGroup( + logic_operator="AND", + conditions=[FilterCondition(column="col4", operator="IN", value=['some value', 'another value'])] ), - ], - ) - def test_query_operation_combinations( - self, - select_columns, - filter, - group_by_columns, - aggregation_conditions, - order_by_columns, - limit, - expected_sql, - ): + order_by_columns=[ + QueryOrderBy(column="col1"), + QueryOrderBy(column="col2", direction="DESC"), + ], + limit=10 + ) + assert sql_query.to_sql("test_domain") == "SELECT col1,col2,col3 FROM test_domain WHERE col2 = 123 GROUP BY col4,col5 HAVING col4 IN ('some value', 'another value') ORDER BY col1 ASC,col2 DESC LIMIT 10" + + def test_query_with_aggregate_functions(self): sql_query = Query( - select_columns=select_columns, - filter=filter, - group_by_columns=group_by_columns, - aggregation_conditions=aggregation_conditions, - order_by_columns=order_by_columns, - limit=limit, + select_columns=["col1", "avg(col2)"], + filter=FilterGroup( + logic_operator="AND", + conditions=[FilterCondition(column="col2", operator=">=", value=100)] + ), + group_by_columns=["col1"], + limit=50 + ) + assert sql_query.to_sql("test_domain") == "SELECT col1,avg(col2) FROM test_domain WHERE col2 >= 100 GROUP BY col1 LIMIT 50" + + +class TestFilterCondition: + def test_filter_with_greater_than_operator(self): + filter_condition = FilterCondition(column="age", operator=">", value=18) + assert filter_condition.to_sql() == "age > 18" + + def test_filter_with_equals_operator(self): + filter_condition = FilterCondition(column="status", operator="=", value="active") + assert filter_condition.to_sql() == "status = 'active'" + + def test_filter_with_like_operator(self): + filter_condition = FilterCondition(column="name", operator="LIKE", value="John%") + assert filter_condition.to_sql() == "name LIKE 'John%'" + + def test_filter_with_in_operator(self): + filter_condition = FilterCondition(column="status", operator="IN", value=["active", "pending"]) + assert filter_condition.to_sql() == "status IN ('active', 'pending')" + + def test_filter_with_is_null_operator(self): + filter_condition = FilterCondition(column="deleted_at", operator="IS NULL") + assert filter_condition.to_sql() == "deleted_at IS NULL" + + def test_filter_with_is_not_null_operator(self): + filter_condition = FilterCondition(column="created_at", operator="IS NOT NULL") + assert filter_condition.to_sql() == "created_at IS NOT NULL" + + def test_filter_with_boolean_value(self): + filter_condition = FilterCondition(column="is_active", operator="=", value=True) + assert filter_condition.to_sql() == "is_active = TRUE" + + def test_sql_injection_prevention_in_column_name(self): + with pytest.raises(ValueError, match="Invalid column name"): + FilterCondition(column="col1; DROP TABLE users; --", operator="=", value="test") + + def test_sql_injection_prevention_in_value(self): + filter_condition = FilterCondition(column="name", operator="=", value="O'Brien") + # Single quotes should be escaped by doubling them + assert filter_condition.to_sql() == "name = 'O''Brien'" + + def test_sql_injection_prevention_complex_value(self): + filter_condition = FilterCondition(column="comment", operator="=", value="test'; DROP TABLE users; --") + assert filter_condition.to_sql() == "comment = 'test''; DROP TABLE users; --'" + + def test_invalid_column_name_with_special_chars(self): + with pytest.raises(ValueError, match="Invalid column name"): + FilterCondition(column="col1 OR 1=1", operator="=", value="test") + + def test_valid_column_name_with_dot(self): + filter_condition = FilterCondition(column="users.age", operator=">", value=18) + assert filter_condition.to_sql() == "users.age > 18" + + def test_null_operator_rejects_value(self): + with pytest.raises(ValueError, match="should not have a value"): + FilterCondition(column="deleted_at", operator="IS NULL", value="something") + + def test_comparison_operator_requires_value(self): + with pytest.raises(ValueError, match="requires a value"): + FilterCondition(column="age", operator=">", value=None) + + +class TestFilterGroup: + def test_single_condition_no_logic_operator(self): + group = FilterGroup( + conditions=[ + FilterCondition(column="age", operator=">", value=18) + ] + ) + assert group.to_sql() == "age > 18" + + def test_logic_operator_required_for_multiple_conditions(self): + with pytest.raises(ValueError, match="logic_operator is required"): + group = FilterGroup( + conditions=[ + FilterCondition(column="age", operator=">", value=18), + FilterCondition(column="status", operator="=", value="active") + ] + ) + group.to_sql() + + def test_simple_or_group(self): + group = FilterGroup( + logic_operator="OR", + conditions=[ + FilterCondition(column="column1", operator="=", value=7), + FilterCondition(column="column1", operator="=", value=8) + ] + ) + assert group.to_sql() == "column1 = 7 OR column1 = 8" + + def test_simple_and_group(self): + group = FilterGroup( + logic_operator="AND", + conditions=[ + FilterCondition(column="age", operator=">", value=18), + FilterCondition(column="status", operator="=", value="active") + ] ) + assert group.to_sql() == "age > 18 AND status = 'active'" - assert sql_query.to_sql("test_domain") == expected_sql + def test_or_group_in_query(self): + query = Query( + filter=FilterGroup( + logic_operator="OR", + conditions=[ + FilterCondition(column="column1", operator="=", value=7), + FilterCondition(column="column1", operator="=", value=8) + ] + ) + ) + assert query.to_sql("users") == "SELECT * FROM users WHERE column1 = 7 OR column1 = 8" + + def test_and_group_in_query(self): + query = Query( + filter=FilterGroup( + logic_operator="AND", + conditions=[ + FilterCondition(column="age", operator=">", value=18), + FilterCondition(column="status", operator="=", value="active") + ] + ) + ) + assert query.to_sql("users") == "SELECT * FROM users WHERE age > 18 AND status = 'active'" + + def test_nested_groups_or_within_and(self): + query = Query( + filter=FilterGroup( + logic_operator="AND", + conditions=[ + FilterCondition(column="column2", operator=">", value=10) + ], + groups=[ + FilterGroup( + logic_operator="OR", + conditions=[ + FilterCondition(column="column1", operator="=", value=7), + FilterCondition(column="column1", operator="=", value=8) + ] + ) + ] + ) + ) + assert query.to_sql("users") == "SELECT * FROM users WHERE column2 > 10 AND (column1 = 7 OR column1 = 8)" + + def test_nested_groups_and_within_or(self): + query = Query( + filter=FilterGroup( + logic_operator="OR", + conditions=[ + FilterCondition(column="is_admin", operator="=", value=True) + ], + groups=[ + FilterGroup( + logic_operator="AND", + conditions=[ + FilterCondition(column="age", operator=">", value=18), + FilterCondition(column="status", operator="=", value="active") + ] + ) + ] + ) + ) + assert query.to_sql("users") == "SELECT * FROM users WHERE is_admin = TRUE OR (age > 18 AND status = 'active')" + + def test_complex_nested_groups(self): + query = Query( + filter=FilterGroup( + logic_operator="AND", + groups=[ + FilterGroup( + logic_operator="OR", + conditions=[ + FilterCondition(column="a", operator="=", value=1), + FilterCondition(column="a", operator="=", value=2) + ] + ), + FilterGroup( + logic_operator="OR", + conditions=[ + FilterCondition(column="b", operator="=", value=3), + FilterCondition(column="b", operator="=", value=4) + ] + ) + ] + ) + ) + assert query.to_sql("test") == "SELECT * FROM test WHERE (a = 1 OR a = 2) AND (b = 3 OR b = 4)" + + def test_or_with_in_operator(self): + query = Query( + filter=FilterGroup( + logic_operator="OR", + conditions=[ + FilterCondition(column="status", operator="IN", value=["active", "pending"]), + FilterCondition(column="is_admin", operator="=", value=True) + ] + ) + ) + assert query.to_sql("users") == "SELECT * FROM users WHERE status IN ('active', 'pending') OR is_admin = TRUE" + + def test_aggregation_with_or(self): + query = Query( + select_columns=["category", "COUNT(*)"], + group_by_columns=["category"], + aggregation_conditions=FilterGroup( + logic_operator="OR", + conditions=[ + FilterCondition(column="COUNT(*)", operator=">", value=100), + FilterCondition(column="SUM(price)", operator=">", value=1000) + ] + ) + ) + assert query.to_sql("products") == "SELECT category,COUNT(*) FROM products GROUP BY category HAVING COUNT(*) > 100 OR SUM(price) > 1000" diff --git a/docs/api/query.md b/docs/api/query.md index 4d07ec0e..36758e01 100644 --- a/docs/api/query.md +++ b/docs/api/query.md @@ -13,32 +13,127 @@ There are six values you can customise: - Can contain renaming of columns e.g.: `"col1 AS custom_name"` - `filter` - How to filter the data - - This is provided as a raw SQL string - - Omit the `WHERE` keyword + - This is provided via json and converted to a raw SQL string + - Each condition has `column`, `operator`, and `value` - `group_by_columns` - Which columns to group by - List of column names as strings - `aggregation_conditions` - - What conditions you want to apply to aggregated values - - This is provided as a raw SQL string - - Omit the `HAVING` keyword + - What conditions you want to apply to aggregated values (HAVING clause) + - This is provided via json and converted to a raw SQL string + - Same structure as `filter` - `order_by_columns` - By which column(s) to order the data - - List of strings + - List of objects with `column` and `direction` ("ASC" or "DESC") - Defaults to ascending (`ASC`) if not provided -- `limit` - How many rows to limit the results to - String of an integer +- `limit` - How many rows to limit the results to - Integer -For example: +## Filter Format + +### Single Condition + +```json +{ + "filter": { + "conditions": [ + {"column": "age", "operator": ">", "value": 18} + ] + } +} +``` + +### AND Filter + +Combine multiple conditions with AND or OR operators: + +```json +{ + "filter": { + "logic_operator": "AND", + "conditions": [ + {"column": "age", "operator": ">", "value": 18}, + {"column": "status", "operator": "=", "value": "active"} + ] + } +} +``` + +```json +{ + "filter": { + "logic_operator": "OR", + "conditions": [ + {"column": "priority", "operator": "=", "value": "high"}, + {"column": "priority", "operator": "=", "value": "urgent"} + ] + } +} +``` + +### Complex Nested Filters + +Nest FilterGroups for complex boolean expressions: + +```json +{ + "filter": { + "logic_operator": "AND", + "conditions": [ + {"column": "status", "operator": "=", "value": "active"} + ], + "groups": [ + { + "logic_operator": "OR", + "conditions": [ + {"column": "category", "operator": "=", "value": "electronics"}, + {"column": "category", "operator": "=", "value": "books"} + ] + } + ] + } +} +``` + +This generates SQL: `WHERE status = 'active' AND (category = 'electronics' OR category = 'books')` + +### Available Operators + +| Operator | Description | Example | +|----------|-------------|---------| +| `=` | Equals | `{"column": "age", "operator": "=", "value": 25}` | +| `!=` | Not equals | `{"column": "status", "operator": "!=", "value": "deleted"}` | +| `>` | Greater than | `{"column": "price", "operator": ">", "value": 100}` | +| `>=` | Greater than or equal | `{"column": "age", "operator": ">=", "value": 18}` | +| `<` | Less than | `{"column": "stock", "operator": "<", "value": 10}` | +| `<=` | Less than or equal | `{"column": "discount", "operator": "<=", "value": 50}` | +| `LIKE` | Pattern matching | `{"column": "name", "operator": "LIKE", "value": "John%"}` | +| `NOT LIKE` | Negative pattern matching | `{"column": "email", "operator": "NOT LIKE", "value": "%test%"}` | +| `IN` | Value in list | `{"column": "status", "operator": "IN", "value": ["active", "pending"]}` | +| `NOT IN` | Value not in list | `{"column": "role", "operator": "NOT IN", "value": ["admin", "superuser"]}` | +| `IS NULL` | Value is null | `{"column": "deleted_at", "operator": "IS NULL"}` (no value) | +| `IS NOT NULL` | Value is not null | `{"column": "created_at", "operator": "IS NOT NULL"}` (no value) | + +## Complete Example ```json { "select_columns": ["col1", "avg(col2)"], - "filter": "col2 >= 10", + "filter": { + "logic_operator": "AND", + "conditions": [ + {"column": "col2", "operator": ">=", "value": 10} + ] + }, "group_by_columns": ["col1"], - "aggregation_conditions": "avg(col2) <= 15", + "aggregation_conditions": { + "logic_operator": "AND", + "conditions": [ + {"column": "avg(col2)", "operator": "<=", "value": 15} + ] + }, "order_by_columns": [ { "column": "col1", @@ -49,8 +144,19 @@ For example: "direction": "ASC" } ], - "limit": "30" + "limit": 30 } ``` +This generates SQL: +```sql +SELECT col1, avg(col2) +FROM table +WHERE col2 >= 10 +GROUP BY col1 +HAVING avg(col2) <= 15 +ORDER BY col1 DESC, col2 ASC +LIMIT 30 +``` + > Note: If you do not specify a customised query, and only provide the domain and dataset, you will **select the entire dataset**