Skip to content

Commit

Permalink
Merge pull request #1 from baluyotraf/initial
Browse files Browse the repository at this point in the history
Initial alpha version
  • Loading branch information
baluyotraf authored Mar 17, 2024
2 parents a7854aa + d5db394 commit 993431c
Show file tree
Hide file tree
Showing 8 changed files with 465 additions and 1 deletion.
28 changes: 28 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v2.3.0
hooks:
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace

- repo: https://github.com/pre-commit/mirrors-prettier
rev: "v3.1.0"
hooks:
- id: prettier
args: [--prose-wrap, always]
types_or: [ markdown ]

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.3.3
hooks:
- id: ruff
args: [ --fix ]
- id: ruff-format

- repo: https://github.com/RobertCraigie/pyright-python
rev: v1.1.354
hooks:
- id: pyright
language: system
types: [python]
140 changes: 139 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,139 @@
# altqq
[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit)](https://github.com/pre-commit/pre-commit)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
[![Checked with pyright](https://microsoft.github.io/pyright/img/pyright_badge.svg)](https://microsoft.github.io/pyright/)

# Alternative Queries

Alternative queries is a library created to help with handcrafted SQL queries.
It works by providing a class that represent the queries, with parameters type
checked by `Pydantic`.

The library is currently still in development and has an alpha release.

## Installation

The library is available in PyPI

```bash
pip install altqq
```

## Basic Usage

To use the library, you can define a class that represents a query. Then this
query can be converted to plain text or `pyodbc` usable query.

```python
import altqq

class SelectUserByFirstName(altqq.Query):
__query__ = """
SELECT * FROM "Users"
WHERE first_name = {first_name}
"""
first_name: str

q = altqq.to_pyodbc(SelectUserByFirstName(first_name="arietta"))
print(q.query)
print(q.parameters)
```

Running the code above should give the result below:

```bash

SELECT * FROM "Users"
WHERE first_name = ?

['arietta']
```

## Templating Non-Parameters

By default, the class properties are treated as parameters. If there's a need
for more customization, they can be declared as `altqq.NonParameter`.

```python
import altqq

class SelectByFirstName(altqq.Query):
__query__ = """
SELECT * FROM "{table}"
WHERE first_name = {first_name}
"""
first_name: str
table: altqq.NonParameter[str]

q = altqq.to_pyodbc(SelectByFirstName(
first_name="arietta",
table="Users"
))
print(q.query)
print(q.parameters)
```

Running the code above should give the result below:

```bash

SELECT * FROM "Users"
WHERE first_name = ?

['arietta']
```

## Nested Queries

Queries can also use other queries. When passed to the functions for conversion,
other queries will also be converted.

```python
import altqq

class SelectUserByFirstName(altqq.Query):
__query__ = """
SELECT * FROM "Users"
WHERE first_name = {first_name}
"""
first_name: str


class SelectSubqueryByAge(altqq.Query):
__query__ = """
SELECT * FROM ({subquery}) AS tbl
WHERE tbl.age = {age}
"""
age: int
subquery: altqq.Query

q = altqq.to_pyodbc(SelectSubqueryByAge(
age=20,
subquery=SelectUserByFirstName(
first_name="arietta"
)
))
print(q.query)
print(q.parameters)
```

Running the code above should give the result below:

```bash

SELECT * FROM (
SELECT * FROM "Users"
WHERE first_name = ?
) AS tbl
WHERE tbl.age = ?

['arietta', 20]
```

## Road Map

Below is the list of things planned for the library

- Documentation Page
- Tests
- Expansion of Supported Version
- Support for other Python Database Tooling
32 changes: 32 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
[tool.pyright]
include = ["src"]
strict = ["src"]

[tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "I", "D"]
ignore = ["D107"]

[tool.ruff.lint.pydocstyle]
convention = "google"

[tool.poetry]
name = "altqq"
version = "0.0.1a1"
description = "Alternative Queries: Typed and Composable Tool for Handwritten SQL"
authors = ["baluyotraf <baluyotraf@outlook.com>"]
maintainers = ["baluyotraf <baluyotraf@outlook.com>"]
license = "MIT"
readme = "README.md"
repository = "https://github.com/baluyotraf/altqq"
keywords = ["database", "SQL"]

[tool.poetry.dependencies]
python = "^3.9"
pydantic = "^2.6.4"

[tool.poetry.group.dev.dependencies]
pyright = "^1.1.354"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
41 changes: 41 additions & 0 deletions src/altqq/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Main entry point for the altqq library."""

from altqq.structs import Query
from altqq.translators.plain_text import PlainTextTranslator
from altqq.translators.pyodbc import PyODBCQuery, PyODBCTranslator
from altqq.types import NonParameter as NonParameter


class Translators:
"""Definition of available translators."""

PYODBC = PyODBCTranslator()
PLAIN_TEXT = PlainTextTranslator()


def to_pyodbc(query: Query) -> PyODBCQuery:
"""Converts a `Query` to its corresponding `PyODBCQuery` object.
Args:
query (Query): Query to translate to PyODBC
Returns:
PyODBCQuery: Equivalent query for PyODBC usage.
"""
return Translators.PYODBC(query)


def to_plain_text(query: Query) -> str:
"""Converts a `Query` to a plain text SQL.
The conversion to plain text also handles some of the data types. None
is converted to `NULL`, numeric values are written as they are and
string values and other object types are escaped using `'`.
Args:
query (Query): Query to convert.
Returns:
str: Query as plain text.
"""
return Translators.PLAIN_TEXT(query)
55 changes: 55 additions & 0 deletions src/altqq/structs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Structures used for defining queries."""

from typing import Any, ClassVar, Dict, Tuple

import pydantic.dataclasses as dc
from typing_extensions import dataclass_transform

QUERY_ATTRIB = "__query__"


@dataclass_transform()
class QueryMeta(type):
"""Metaclass for generating Query objects that the library supports.
Classes using this metaclass are automatically converted to Pydantic
Dataclasses for validation support. Also, the `__query__` attribute is
verified to be provided either as a value or as a type hint.
Raises:
ValueError: When the `__query__` attribute is not defined.
Returns:
_type_: A new type compatible for the library query functionalities.
"""

@staticmethod
def _check_query_attribute(dataclass: "QueryMeta", dct: Dict[str, Any]):
try:
if isinstance(getattr(dataclass, QUERY_ATTRIB), str):
return True
except AttributeError:
return QUERY_ATTRIB in dct["__annotations__"]

return False

def __new__(cls, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]):
"""Creates a new class of the metaclass.
This wraps the newly created class with Pydantic Dataclass and checks
the `__query__` attribute.
"""
dataclass = super().__new__(cls, name, bases, dct)
if not cls._check_query_attribute(dataclass, dct):
raise ValueError(f"A string {QUERY_ATTRIB} must be provided")
return dc.dataclass(dataclass)


class Query(metaclass=QueryMeta):
"""Base class for query definitions.
This class can be inherited instead of providing the `QueryMeta` as the
class metaclass.
"""

__query__: ClassVar[str]
51 changes: 51 additions & 0 deletions src/altqq/translators/plain_text.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Module for converting Query objects to plain text SQL."""

import dataclasses as dc
import typing
from typing import Any

from altqq.structs import Query
from altqq.types import QueryValueTypes, T
from typing_extensions import Annotated


class PlainTextTranslator:
"""Converts a `Query` to a plain text SQL."""

def _resolve_value(self, query: Query, field: dc.Field[T]) -> Any:
value = getattr(query, field.name)
if field.type == Query:
return self.__call__(value)

if typing.get_origin(field.type) == Annotated:
# Pyright can't match the __metadata__ attribute to Annotated
if QueryValueTypes.NON_PARAMETER in field.type.__metadata__: # type: ignore
return value

# Numeric types are not escaped
if isinstance(value, (int, float)):
return value

# None is written as NULL
if value is None:
return "NULL"

# All other types fall down to strings and are escaped
return f"'{value}'"

def __call__(self, query: Query) -> str:
"""Converts a `Query` to a plain text SQL.
The conversion to plain text also handles some of the data types. None
is converted to `NULL`, numeric values are written as they are and
string values and other object types are escaped using `'`.
Args:
query (Query): Query to convert.
Returns:
str: Query as plain text.
"""
fields = dc.fields(query)
format_dict = {f.name: self._resolve_value(query, f) for f in fields}
return query.__query__.format(**format_dict)
Loading

0 comments on commit 993431c

Please sign in to comment.