Skip to content

Commit

Permalink
setup GH actions
Browse files Browse the repository at this point in the history
* black
* mypy
* pytest with coverage
  • Loading branch information
baverman committed Mar 5, 2024
1 parent ed1dc26 commit e74b795
Show file tree
Hide file tree
Showing 8 changed files with 120 additions and 32 deletions.
5 changes: 5 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[run]
source = sqlbind

[report]
fail_under = 100
51 changes: 51 additions & 0 deletions .github/workflows/app.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: sqlbind

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

permissions:
contents: read

jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- python: "3.6"
os: "ubuntu-20.04"
- python: "3.8"
os: "ubuntu-20.04"
- python: "3.12"
os: "ubuntu-22.04"
steps:
- uses: actions/checkout@v4
- name: prepare
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
- name: deps
run: |
pip install -r requirements-test.txt
- name: test
run: python -m coverage run -m pytest
- name: coverage
run: python -m coverage report -m

lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: prepare
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: deps
run: pip install -r requirements-lint.txt
- name: black
run: black --check .
- name: mypy
run: mypy --strict sqlbind
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"

[tool.black]
line-length = 120
target-version = ['py36']
skip-string-normalization = true
2 changes: 2 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[pytest]
addopts = --doctest-glob README.md --doctest-modules
2 changes: 2 additions & 0 deletions requirements-lint.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
mypy==1.8.0
black==24.2.0
2 changes: 2 additions & 0 deletions requirements-test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
coverage
pytest
47 changes: 34 additions & 13 deletions sqlbind/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class Expr(str):
>>> (q('name = {}', 'bob') | 'enabled = 1')
'(name = ? OR enabled = 1)'
"""

def __or__(self, other: str) -> 'Expr':
return OR(self, other)

Expand Down Expand Up @@ -192,6 +193,7 @@ class NotNone:
>>> f'SELECT * FROM users WHERE enabled = 1 {AND_(q.age > not_none/age)} {AND_(q.name == not_none/name)}'
'SELECT * FROM users WHERE enabled = 1 AND age > ? '
"""

def __truediv__(self, other: t.Any) -> t.Any:
if other is None:
return UNDEFINED
Expand All @@ -211,6 +213,7 @@ class Truthy:
See NotNone usage
"""

def __truediv__(self, other: t.Any) -> t.Any:
if not other:
return UNDEFINED
Expand All @@ -236,6 +239,7 @@ class cond:
Also see NotNone usage.
"""

def __init__(self, cond: t.Any):
self._cond = cond

Expand Down Expand Up @@ -351,6 +355,7 @@ class QueryParams:
>>> q._('LOWER(name)') == 'bob' # `_()` call allow to use any literal as QExpr
'LOWER(name) = ?'
"""

dialect: t.Type['BaseDialect']
_ = QExprDesc()

Expand Down Expand Up @@ -481,11 +486,13 @@ def eq(self, field__: t.Optional[Str] = None, value__: t.Any = None, **kwargs: t
"""
if field__:
kwargs[str(field__)] = value__
return AND(*(self.compile(f'{field} IS NULL', ())
if value is None
else self.compile(f'{field} = {{}}', (value,))
for field, value in kwargs.items()
if value is not UNDEFINED))
return AND(
*(
self.compile(f'{field} IS NULL', ()) if value is None else self.compile(f'{field} = {{}}', (value,))
for field, value in kwargs.items()
if value is not UNDEFINED
)
)

def neq(self, field__: t.Optional[Str] = None, value__: t.Any = None, **kwargs: t.Any) -> Expr:
"""Opposite to `.eq`
Expand All @@ -495,11 +502,17 @@ def neq(self, field__: t.Optional[Str] = None, value__: t.Any = None, **kwargs:
"""
if field__:
kwargs[str(field__)] = value__
return AND(*(self.compile(f'{field} IS NOT NULL', ())
if value is None
else self.compile(f'{field} != {{}}', (value,))
for field, value in kwargs.items()
if value is not UNDEFINED))
return AND(
*(
(
self.compile(f'{field} IS NOT NULL', ())
if value is None
else self.compile(f'{field} != {{}}', (value,))
)
for field, value in kwargs.items()
if value is not UNDEFINED
)
)

def in_range(self, field: Str, left: t.Any, right: t.Any) -> Expr:
"""Helper to check field is in [left, right) bounds
Expand Down Expand Up @@ -540,9 +553,9 @@ def assign(self, **kwargs: t.Any) -> Expr:
>>> q.assign(name='bob', age=30, confirmed_date=None)
'name = ?, age = ?, confirmed_date = ?'
"""
fragments = [self.compile(f'{field} = {{}}', (value,))
for field, value in kwargs.items()
if value is not UNDEFINED]
fragments = [
self.compile(f'{field} = {{}}', (value,)) for field, value in kwargs.items() if value is not UNDEFINED
]
return join_fragments(', ', fragments)

def SET(self, **kwargs: t.Any) -> str:
Expand Down Expand Up @@ -650,41 +663,47 @@ def add(self, params: t.Sequence[t.Any]) -> int:

class QMarkQueryParams(ListQueryParams):
"""QueryParams implementation for qmark (?) parameter style"""

def compile(self, expr: str, params: t.Sequence[t.Any]) -> str:
self.add(params)
return expr.format(*('?' * len(params)))


class NumericQueryParams(ListQueryParams):
"""QueryParams implementation for numeric (:1, :2) parameter style"""

def compile(self, expr: str, params: t.Sequence[t.Any]) -> str:
start = self.add(params) + 1
return expr.format(*(f':{i}' for i, _ in enumerate(params, start)))


class FormatQueryParams(ListQueryParams):
"""QueryParams implementation for format (%s) parameter style"""

def compile(self, expr: str, params: t.Sequence[t.Any]) -> str:
self.add(params)
return expr.format(*(['%s'] * len(params)))


class NamedQueryParams(DictQueryParams):
"""QueryParams implementation for named (:name) parameter style"""

def compile(self, expr: str, params: t.Sequence[t.Any]) -> str:
names = self.add(params)
return expr.format(*(f':{it}' for it in names))


class PyFormatQueryParams(DictQueryParams):
"""QueryParams implementation for pyformat (%(name)s) parameter style"""

def compile(self, expr: str, params: t.Sequence[t.Any]) -> str:
names = self.add(params)
return expr.format(*(f'%({it})s' for it in names))


class BaseDialect:
"""Dialect compatible with most of backends"""

FALSE = 'FALSE'
LIKE_ESCAPE = '\\'
LIKE_CHARS = '%_'
Expand All @@ -696,6 +715,7 @@ def IN(q: QueryParams, field: Str, values: t.List[t.Any]) -> Expr:

class SQLiteDialect(BaseDialect):
"""Dedicated SQLite dialiect to handle FALSE literal and IN operator"""

FALSE = '0'

@staticmethod
Expand Down Expand Up @@ -724,6 +744,7 @@ def sqlite_value_list(values: t.List[t.Union[float, int, str]]) -> str:

class Dialect:
"""Namespace to hold most popular Dialect/QueryParams combinations"""

def __init__(self, factory: t.Callable[[], QueryParams]):
self.factory = factory

Expand Down
38 changes: 19 additions & 19 deletions tests/test_sqlbind.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,27 +55,27 @@ def test_conditions():
def test_outbound_conditions():
q = s.Dialect.default()

assert q('field = {}', s.cond(True)/10) == 'field = ?'
assert q('field = {}', s.cond(False)/10) == ''
assert q('field = {}', s.cond(True) / 10) == 'field = ?'
assert q('field = {}', s.cond(False) / 10) == ''

assert q.eq('field', s.not_none/20) == 'field = ?'
assert q.eq('field', s.not_none/None) == ''
assert q.eq('field', s.not_none / 20) == 'field = ?'
assert q.eq('field', s.not_none / None) == ''

assert q('field = {}', s.truthy/30) == 'field = ?'
assert q('field = {}', s.truthy/0) == ''
assert q('field = {}', s.truthy / 30) == 'field = ?'
assert q('field = {}', s.truthy / 0) == ''
assert q == [10, 20, 30]


def test_query_methods():
q = s.Dialect.default()
assert q.IN('field', s.truthy/0) == ''
assert q.IN('field', s.truthy / 0) == ''
assert q.IN('field', None) == ''
assert q.IN('field', []) == 'FALSE'
assert q.field.IN([10]) == 'field IN ?'

assert q.eq('t.bar', None, boo='foo') == '(boo = ? AND t.bar IS NULL)'
assert q.neq('t.bar', None, boo='foo') == '(boo != ? AND t.bar IS NOT NULL)'
assert q.neq('t.bar', s.not_none/None, boo=s.not_none/None) == ''
assert q.neq('t.bar', s.not_none / None, boo=s.not_none / None) == ''

assert q == [[10], 'foo', 'foo']

Expand All @@ -87,7 +87,7 @@ def test_query_methods():

def test_bind():
q = s.Dialect.sqlite()
assert q/10 == '?'
assert q / 10 == '?'
assert q == [10]


Expand Down Expand Up @@ -137,14 +137,14 @@ def test_prefix_join():
def test_prepend():
q = s.Dialect.default()
assert s.AND_('') == ''
assert s.AND_(q.f == s.not_none/None) == ''
assert s.AND_(q.f == s.not_none/10) == 'AND f = ?'
assert s.AND_(q.f == s.not_none / None) == ''
assert s.AND_(q.f == s.not_none / 10) == 'AND f = ?'
assert q == [10]

q = s.Dialect.default()
assert s.OR_('') == ''
assert s.OR_(q.f == s.not_none/None) == ''
assert s.OR_(q.f == s.not_none/10) == 'OR f = ?'
assert s.OR_(q.f == s.not_none / None) == ''
assert s.OR_(q.f == s.not_none / 10) == 'OR f = ?'
assert q == [10]


Expand All @@ -166,9 +166,9 @@ def test_fields():

def test_limit():
q = s.Dialect.default()
assert q.LIMIT(s.not_none/None) == ''
assert q.LIMIT(s.not_none / None) == ''
assert q.LIMIT(20) == 'LIMIT ?'
assert q.OFFSET(s.not_none/None) == ''
assert q.OFFSET(s.not_none / None) == ''
assert q.OFFSET(20) == 'OFFSET ?'


Expand All @@ -183,8 +183,8 @@ def test_qexpr():
assert (q.val != 6) == 'val != ?'
assert q == [1, 2, 3, 4, 5, 6]

assert (q.val == s.not_none/None) is s.EMPTY
assert (q.val == s.truthy/0) is s.EMPTY
assert (q.val == s.not_none / None) is s.EMPTY
assert (q.val == s.truthy / 0) is s.EMPTY
assert q == [1, 2, 3, 4, 5, 6]

q = s.Dialect.default()
Expand All @@ -197,10 +197,10 @@ class Q:
p = s.Dialect(s.Dialect.default)

q1 = Q.p
q1/10
q1 / 10

q2 = Q.p
q2/20
q2 / 20

assert q1 == [10]
assert q2 == [20]
Expand Down

0 comments on commit e74b795

Please sign in to comment.