Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 17 additions & 17 deletions .github/workflows/cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ jobs:
env:
UV_PYTHON: "3.12"
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
cache-dependency-glob: uv.lock
Expand Down Expand Up @@ -49,14 +49,14 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14", "3.14t"]
python-version: ["3.11", "3.12", "3.13", "3.14", "3.14t"]
env:
UV_PYTHON: ${{ matrix.python-version }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
cache-dependency-glob: uv.lock
Expand All @@ -77,10 +77,10 @@ jobs:
env:
UV_PYTHON: "3.12"
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
cache-dependency-glob: uv.lock
Expand All @@ -103,10 +103,10 @@ jobs:
env:
UV_PYTHON: "3.12"
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
cache-dependency-glob: uv.lock
Expand All @@ -119,7 +119,7 @@ jobs:
run: uv build --out-dir dist

- name: Upload distribution
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: python-distribution
path: dist/
Expand All @@ -135,13 +135,13 @@ jobs:
contents: read
steps:
- name: Download distribution
uses: actions/download-artifact@v8
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: python-distribution
path: dist/

- name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: false

Expand All @@ -156,14 +156,14 @@ jobs:
env:
UV_PYTHON: "3.12"
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Configure GitHub Pages
id: pages
uses: actions/configure-pages@v6
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0

- name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0
with:
enable-cache: true
cache-dependency-glob: uv.lock
Expand All @@ -179,7 +179,7 @@ jobs:
run: uv run mkdocs build --strict --site-dir _site

- name: Upload GitHub Pages artifact
uses: actions/upload-pages-artifact@v5
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: _site

Expand All @@ -201,4 +201,4 @@ jobs:
steps:
- name: Deploy GitHub Pages artifact
id: deployment
uses: actions/deploy-pages@v5
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
A compiler-oriented RSQL query engine for safe, typed, and extensible
filtering, sorting, and pagination in Python APIs.

[![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14%20%7C%203.14t-blue?logo=python)](https://pypi.org/project/pyrsql/)
[![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13%20%7C%203.14%20%7C%203.14t-blue?logo=python)](https://pypi.org/project/pyrsql/)
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)

pyrsql compiles RSQL query strings into ORM-specific statement objects
Expand All @@ -16,9 +16,9 @@ For FastAPI applications using SQLAlchemy, the recommended entry point is
declarative resources; the lower-level query, adapter, and ORM APIs remain
available when more control is needed.

**Current backends:** SQLAlchemy 2.0
**Current framework adapters:** FastAPI
**Planned:** Django ORM, SQLModel, Flask
- **Current backends:** SQLAlchemy 2.1+
- **Current framework adapters:** FastAPI
- **Planned:** Django ORM, SQLModel, Flask

## Why pyrsql?

Expand Down Expand Up @@ -241,6 +241,10 @@ Full documentation at **[wskr00.github.io/pyrsql](https://wskr00.github.io/pyrsq
- Strong typing
- ORM-neutral public API

## Acknowledgements

`pyrsql` was strongly inspired by [Jirutka's `rsql-parser`](https://github.com/jirutka/rsql-parser) and [PerplexHub's `rsql-jpa-specification`](https://github.com/perplexhub/rsql-jpa-specification), especially their RSQL syntax and filtering behavior. I am grateful to their maintainers and contributors for their work.

## License

[MIT](LICENSE).
2 changes: 1 addition & 1 deletion docs/explanation/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ The project is designed around four major responsibilities:
- backend lowering: turn validated query objects into ORM-specific statements
- optional framework adapters and integrations: expose the core in HTTP stacks

The current production backend is SQLAlchemy 2.0. The current framework
The current production backend is SQLAlchemy 2.1+. The current framework
adapter is FastAPI.

## Current Pipeline
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ I/O.
## Key features

- **ORM-neutral core** - `Query`, `Sort`, `PageRequest` have zero ORM dependencies
- **Pluggable backends** - SQLAlchemy 2.0 today, Django ORM / SQLModel planned
- **Pluggable backends** - SQLAlchemy 2.1+ today, Django ORM / SQLModel planned
- **Pluggable adapters** - FastAPI today, Flask planned
- **20+ built-in operators** - `==`, `=like=`, `=in=`, `=bt=`, `=nn=`, etc.
- **Custom operators** - define your own RSQL operators with per-ORM lowering
Expand Down
2 changes: 1 addition & 1 deletion docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ from pyrsql import Query
Query.parse("name==demo") # equal
Query.parse("age=gt=18") # greater than
Query.parse("id=in=(1,2,3)") # IN
Query.parse("name=like=demo*") # LIKE
Query.parse("name=like=demo") # contains: LIKE '%demo%'
Query.parse("score=bt=(10,20)") # BETWEEN
Query.parse("name==demo;age=gt=18") # AND
Query.parse("name==demo,age=gt=18") # OR
Expand Down
30 changes: 22 additions & 8 deletions docs/reference/operators.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Operators

Pyrsql supports the base RSQL comparison operators and the extensions used by
PerplexHub's JPA specification.

## Comparison operators

| Operator | Aliases | Arity | Description |
Expand All @@ -12,14 +15,25 @@
| `=le=` | `<=` | 1 | Less or equal |
| `=in=` | | 1..100 | IN (comma-separated args) |
| `=out=` | | 1..100 | NOT IN |
| `=like=` | | 1 | LIKE (supports `*` wildcard) |
| `=notlike=` | | 1 | NOT LIKE |
| `=ic=` | `=ilike=` | 1 | ILIKE (case-insensitive LIKE) |
| `=inotlike=` | | 1 | NOT ILIKE |
| `=bt=` | | 2 | BETWEEN (exactly 2 args) |
| `=nb=` | | 2 | NOT BETWEEN |
| `=na=` | | 0 | IS NULL |
| `=nn=` | | 0 | IS NOT NULL |
| `=like=` | `=ke=` | 1 | Contains match (`LIKE '%value%'`) |
| `=notlike=` | `=nk=` | 1 | Negated contains match |
| `=ilike=` | `=ik=` | 1 | Case-insensitive contains match |
| `=inotlike=` | `=ni=` | 1 | Negated case-insensitive contains match |
| `=ic=` | `=icase=` | 1 | Case-insensitive equality |
| `=bt=` | `=between=` | 2 | BETWEEN (exactly 2 args) |
| `=nb=` | `=notbetween=` | 2 | NOT BETWEEN (exactly 2 args) |
| `=na=` | `=isnull=`, `=null=` | 0..1 | IS NULL |
| `=nn=` | `=notnull=`, `=isnotnull=` | 0..1 | IS NOT NULL |

For `=like=`, `=notlike=`, `=ilike=`, and `=inotlike=`, pyrsql surrounds the
argument with `%` to match anywhere in the value. These operators use SQL
`LIKE` pattern rules: `%` matches any number of characters, `_` matches one,
and `*` is literal. The `like_escape_character` option controls SQL pattern
escaping.

For string-column `==` and `!=`, `*` is the wildcard and any `^` marker
requests case-insensitive matching. Quoting an argument does not disable these
markers. Use `=in=(*)` to match a literal asterisk.

## Logical operators

Expand Down
4 changes: 4 additions & 0 deletions docs/reference/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@

Notes:

- `strict_equality=True` makes string `==` and `!=` compare markers such as
`*` and `^` literally; quoting alone does not disable them
- `like_escape_character` applies to LIKE-based operators, including equality
comparisons that contain `*`
- configuration containers are normalized into immutable views
- procedure allow/block rules are compiled as regex policies and evaluated with
`fullmatch`, not partial match
Expand Down
14 changes: 9 additions & 5 deletions docs/usage/query.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ Query.parse("name=nn=") # IS NOT NULL
## Text matching

```python
Query.parse("name=like=demo*") # LIKE
Query.parse("name=like=demo") # contains: LIKE '%demo%'
Query.parse("name=notlike=demo") # NOT LIKE
Query.parse("name=ic=demo*") # ILIKE (case-insensitive)
Query.parse("name=ilike=DEMO") # ILIKE alias
Query.parse("name=ic=DEMO") # case-insensitive equality
Query.parse("name=ilike=DEMO") # case-insensitive contains
```

## Range
Expand All @@ -54,15 +54,19 @@ Query.parse("(name==demo;age=gt=18),status==active") # grouping

```python
Query.parse("name=='de\\'mo'") # quoted string with escape
Query.parse("name=='*not a wildcard*'") # literal asterisks inside quotes
Query.parse('name=="Kill Bill"') # double-quoted argument
Query.parse("name=='*demo*'") # quotes do not disable equality wildcards
```

## Wildcard matching in equality

By default, `*` in equality expressions is treated as a `LIKE` wildcard:
By default, `*` in string equality expressions is treated as a `LIKE`
wildcard, and `^` makes the equality case-insensitive:

```python
Query.parse("name==*demo*") # becomes LIKE '%demo%'
Query.parse("name==^*DEMO*") # case-insensitive LIKE '%DEMO%'
Query.parse("name=in=(*)") # exact match for a literal asterisk
```

Disable with strict mode:
Expand Down
40 changes: 21 additions & 19 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
[project]
name = "pyrsql"
version = "0.0.4"
version = "0.0.5"
description = "Compiler-oriented RSQL query engine for safe, typed, and extensible Python ORM-backed APIs."
readme = "README.md"
requires-python = ">=3.10"
requires-python = ">=3.11"
license = "MIT"
keywords = ["rsql", "orm", "filter", "pagination", "sort", "sqlalchemy", "fastapi", "rest"]
classifiers = [
Expand All @@ -12,7 +12,6 @@ classifiers = [
"Intended Audience :: Developers",
"Intended Audience :: Information Technology",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
Expand All @@ -25,13 +24,15 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"msgspec>=0.18,<1.0",
"typing-extensions>=4.10.0; python_version < '3.11'",
"msgspec>=0.22.0,<1.0",
]

[project.optional-dependencies]
sqlalchemy = ["sqlalchemy>=2.0,<3.0"]
fastapi = ["fastapi>=0.110,<1.0"]
sqlalchemy = [
"sqlalchemy[asyncio]>=2.1,<3.0",
"typing-extensions>=4.10.0",
]
fastapi = ["fastapi>=0.150.0,<1.0"]

[project.urls]
Homepage = "https://github.com/wskr00/pyrsql"
Expand All @@ -47,31 +48,32 @@ dev = [
{ include-group = "docs" },
]
lint = [
"ruff>=0.11.0",
"ruff>=0.15.21",
]
test = [
"pytest>=8.2.0",
"httpx>=0.27.0",
"pytest-cov>=5.0.0",
"aiosqlite>=0.20.0",
"sqlalchemy[asyncio]>=2.0,<3.0",
"pytest>=9.1.1",
"httpx>=0.28.1",
"pytest-cov>=7.1.0",
"aiosqlite>=0.21.0,<0.22.0",
"sqlalchemy[asyncio]>=2.1,<3.0",
"typing-extensions>=4.10.0",
]
typing = ["mypy>=2.0.0"]
typing = ["mypy>=2.2.0"]
docs = [
"mkdocs-material>=9.5",
"mkdocstrings[python]>=0.27",
"mkdocs-material>=9.7.6",
"mkdocstrings[python]>=1.0.5",
]

[build-system]
requires = ["uv_build>=0.11.6,<0.12.0"]
requires = ["uv_build>=0.13.1,<0.14.0"]
build-backend = "uv_build"

[tool.uv]
managed = true

[tool.ruff]
line-length = 80
target-version = "py310"
target-version = "py311"
src = ["src", "tests"]
fix = true

Expand Down Expand Up @@ -192,7 +194,7 @@ markers = [
]

[tool.mypy]
python_version = "3.10"
python_version = "3.11"
files = ["src"]
exclude = ["^tests/"]
strict = true
Expand Down
4 changes: 2 additions & 2 deletions src/pyrsql/core/json/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from __future__ import annotations

from collections.abc import Mapping
from enum import Enum
from enum import StrEnum
import re
from types import MappingProxyType
from typing import TypeAlias
Expand All @@ -14,7 +14,7 @@
SortFieldTypeMap: TypeAlias = Mapping[str, "JSONSortScalarType"]


class JSONSortScalarType(str, Enum):
class JSONSortScalarType(StrEnum):
"""Supported scalar cast strategies for JSON sort expressions."""

TEXT = "text"
Expand Down
2 changes: 1 addition & 1 deletion src/pyrsql/orms/sqlalchemy/orm.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@


class SQLAlchemyORM(base.ORM):
"""ORM adapter for SQLAlchemy 2.0 integration."""
"""ORM adapter for SQLAlchemy 2.1+ integration."""

__slots__ = ("_sort_translator", "_translator")

Expand Down
4 changes: 2 additions & 2 deletions src/pyrsql/orms/sqlalchemy/translator.py
Original file line number Diff line number Diff line change
Expand Up @@ -490,8 +490,8 @@ def _build_string_equality_predicate(
if options.strict_equality:
return expression == value

ignore_case = value.startswith("^")
normalized_value = value[1:] if ignore_case else value
ignore_case = "^" in value
normalized_value = value.replace("^", "") if ignore_case else value
if "*" in normalized_value:
pattern = normalized_value.replace("*", "%")
return self._build_pattern_predicate(
Expand Down
Loading
Loading