Skip to content

Commit

Permalink
feat: added json schema to validate query params, added unit tests (#4)
Browse files Browse the repository at this point in the history
* feat: query tests added

* feat: added test query for in/nin with numbers

* feat: added json schema to validate query params, added unit tests

* feat: added select fields validation
  • Loading branch information
ymarcon authored Nov 28, 2024
1 parent d71fc2c commit b2dec9a
Show file tree
Hide file tree
Showing 7 changed files with 1,023 additions and 8 deletions.
33 changes: 33 additions & 0 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python

name: Python package

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

jobs:
build:

runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11"]

steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install poetry
python -m poetry install
- name: Test with pytest
run: |
python -m poetry run pytest
173 changes: 173 additions & 0 deletions enacit4r_sql/schemas/query-schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ENAC-IT4R Python SQL Utils: Query Schema",
"type": "object",
"properties": {
"filter": {
"type": "object",
"additionalProperties": {
"anyOf": [
{ "$ref": "#/definitions/condition" },
{ "type": "number" },
{ "type": "string" },
{
"type": "array",
"items": { "$ref": "#/definitions/condition" }
}
]
}
},
"sort": {
"type": "array",
"items": [
{ "type": "string" },
{ "type": "string", "enum": ["ASC", "DESC", "asc", "desc"], "default": "ASC" }
],
"minItems": 0,
"maxItems": 2
},
"range": {
"type": "array",
"items": { "type": "integer" },
"minItems": 0,
"maxItems": 2
},
"fields": {
"type": "array",
"items": { "type": "string" },
"minItems": 0
}
},
"definitions": {
"condition": {
"type": "object",
"anyOf": [
{
"type": "array",
"items": { "$ref": "#/definitions/condition" }
},
{
"type": "object",
"additionalProperties": {
"anyOf": [
{ "$ref": "#/definitions/condition" },
{
"type": "array",
"items": { "$ref": "#/definitions/condition" }
}
]
}
},
{
"type": "object",
"properties": {
"$eq": { "type": ["string", "number"] }
},
"required": ["$eq"]
},
{
"type": "object",
"properties": {
"$ne": { "type": ["string", "number"] }
},
"required": ["$ne"]
},
{
"type": "object",
"properties": {
"$lt": { "type": "number" }
},
"required": ["$lt"]
},
{
"type": "object",
"properties": {
"$lte": { "type": "number" }
},
"required": ["$lte"]
},
{
"type": "object",
"properties": {
"$le": { "type": "number" }
},
"required": ["$le"]
},
{
"type": "object",
"properties": {
"$gt": { "type": "number" }
},
"required": ["$gt"]
},
{
"type": "object",
"properties": {
"$gte": { "type": "number" }
},
"required": ["$gte"]
},
{
"type": "object",
"properties": {
"$ge": { "type": "number" }
},
"required": ["$ge"]
},
{
"type": "object",
"properties": {
"$ilike": { "type": "string" }
},
"required": ["$ilike"]
},
{
"type": "object",
"properties": {
"$like": { "type": "string" }
},
"required": ["$like"]
},
{
"type": "object",
"properties": {
"$contains": {
"type": "array",
"items": { "type": ["string", "number"] }
}
},
"required": ["$contains"]
},
{
"type": "object",
"properties": {
"$in": {
"type": "array",
"items": { "type": ["string", "number"] }
}
},
"required": ["$in"]
},
{
"type": "object",
"properties": {
"$nin": {
"type": "array",
"items": { "type": ["string", "number"] }
}
},
"required": ["$nin"]
},
{
"type": "object",
"properties": {
"$exists": { "type": "boolean" }
},
"required": ["$exists"]
}
]
}
},
"required": ["filter"],
"additionalProperties": false
}
93 changes: 87 additions & 6 deletions enacit4r_sql/utils/query.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,110 @@
from sqlmodel import SQLModel, select
from sqlalchemy import func, or_, and_, cast, String, false, true
import json
from importlib import resources
from jsonschema import validate

class ValidationError(Exception):
"""Exception raised for errors in the input parameters."""
pass

def paramAsDict(param: str):
"""Parse a JSON string into a dictionary
Args:
param (str): JSON string
Returns:
dict: Dictionary representation of the JSON string, empty dictionary if param is None
"""
return json.loads(param) if param else {}


def paramAsArray(param: str):
"""Parse a JSON string as a list
Args:
param (str): JSON string
Returns:
list: List representation of the JSON string, empty list if param is None
"""
return json.loads(param) if param else []


def validate_params(filter: dict | str, sort: list | str, range: list | str, fields: list | str = []) -> dict:
"""Validate filter, sort and range parameters against a JSON schema.
Args:
filter (dict | str): Filter parameters
sort (list | str): Sort parameters
range (list | str): Range parameters
fields (list | str): Fields to retrieve
Returns:
dict: The validated parameters as a dictionary
Raises:
ValidationError: If the parameters are not valid
"""
package_name = "enacit4r_sql.schemas"
resource_name = "query-schema.json"
with resources.open_text(package_name, resource_name) as json_file:
schema = json.load(json_file)
to_validate = {
"filter": filter if isinstance(filter, dict) else paramAsDict(filter),
"sort": sort if isinstance(sort, list) else paramAsArray(sort),
"range": range if isinstance(range, list) else paramAsArray(range),
"fields": fields if isinstance(fields, list) else paramAsArray(fields),
}
try:
validate(instance=to_validate, schema=schema)
except Exception as e:
raise ValidationError(f"Invalid query parameters: {e}")
return to_validate


class QueryBuilder:
"""Helper class to generate SQL queries based on filter, sort and range parameters, based on a provided model. Limited support for join queries.
"""

def __init__(self, model: SQLModel, filter: dict, sort: list, range: list, joinModels: dict = {}):
def __init__(self, model: SQLModel, filter: dict, sort: list, range: list, joinModels: dict = {}, validate: bool = False):
"""Initialize the QueryBuilder object with the provided parameters.
Args:
model (SQLModel): The model to query
filter (dict): Filter parameters
sort (list): Sort parameters
range (list): Range parameters
joinModels (dict, optional): Dictionary of join models. Defaults to {}.
validate (bool, optional): Whether to validate the parameters. Defaults to False.
"""
if validate:
validate_params(filter, sort, range)
self.model = model
self.filter = filter
self.sort = sort
self.range = range
self.joinModels = joinModels

def build_count_query(self):
"""Count the number of rows that match the filter.
Returns:
int: The total count of rows that match the filter.
"""
return self._apply_filter(select(func.count(func.distinct(self.model.id))))

def build_query(self, total_count, fields=None):
"""Build a query that retrieves rows that match the filter, sorted and ranged as specified.
Args:
total_count (int): Total number of rows that match the filter.
fields (list, optional): List of fields to retrieve. Defaults to None.
Returns:
tuple: A tuple containing the start index, end index and the query object.
"""
_query = select(self.model)
if fields and len(fields):
columns = [getattr(self.model, field) for field in fields]
Expand All @@ -32,10 +113,6 @@ def build_query(self, total_count, fields=None):
query_ = self._apply_sort(query_)
return self._apply_range(query_, total_count)

def build_filter_query(self, query_from):
query_ = self._apply_filter(query_from)
return query_

def _apply_filter(self, query_):
return self._apply_model_filter(query_, self.model, self.filter)

Expand Down Expand Up @@ -117,7 +194,7 @@ def _make_filter_value(self, field, column, value):
elif isinstance(value, dict):
clause = self._make_filter_object(field, column, value)
else:
clause = (column.ilike(f"%{value}%"))
clause = column == value
return clause

def _make_filter_object(self, field, column, value):
Expand Down Expand Up @@ -169,6 +246,10 @@ def _apply_sort(self, query_):
query_ = query_.order_by(attr.desc())
else:
query_ = query_.order_by(attr)
elif len(self.sort) == 1:
sort_field = self.sort[0]
attr = getattr(self.model, sort_field)
query_ = query_.order_by(attr)
return query_

def _apply_range(self, query_, total_count):
Expand Down
Loading

0 comments on commit b2dec9a

Please sign in to comment.