pyrsql 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. pyrsql/__init__.py +63 -0
  2. pyrsql/adapters/__init__.py +3 -0
  3. pyrsql/adapters/fastapi/__init__.py +15 -0
  4. pyrsql/adapters/fastapi/config.py +76 -0
  5. pyrsql/adapters/fastapi/criteria.py +56 -0
  6. pyrsql/adapters/fastapi/dependency.py +217 -0
  7. pyrsql/adapters/fastapi/errors.py +107 -0
  8. pyrsql/api.py +76 -0
  9. pyrsql/core/__init__.py +45 -0
  10. pyrsql/core/compiler.py +81 -0
  11. pyrsql/core/conversion.py +206 -0
  12. pyrsql/core/custom.py +14 -0
  13. pyrsql/core/field_policy.py +97 -0
  14. pyrsql/core/joins.py +11 -0
  15. pyrsql/core/json/__init__.py +19 -0
  16. pyrsql/core/json/options.py +31 -0
  17. pyrsql/core/json/path.py +35 -0
  18. pyrsql/core/json/query.py +39 -0
  19. pyrsql/core/json/values.py +72 -0
  20. pyrsql/core/options.py +416 -0
  21. pyrsql/core/page.py +115 -0
  22. pyrsql/core/procedure_policy.py +61 -0
  23. pyrsql/core/query.py +160 -0
  24. pyrsql/core/sort.py +153 -0
  25. pyrsql/integrations/__init__.py +8 -0
  26. pyrsql/integrations/fastapi/__init__.py +8 -0
  27. pyrsql/integrations/fastapi/sqlalchemy.py +251 -0
  28. pyrsql/orms/__init__.py +15 -0
  29. pyrsql/orms/base.py +99 -0
  30. pyrsql/orms/sqlalchemy/__init__.py +39 -0
  31. pyrsql/orms/sqlalchemy/coercion.py +57 -0
  32. pyrsql/orms/sqlalchemy/compiled.py +56 -0
  33. pyrsql/orms/sqlalchemy/compiled_page.py +40 -0
  34. pyrsql/orms/sqlalchemy/compiled_sort.py +56 -0
  35. pyrsql/orms/sqlalchemy/custom.py +25 -0
  36. pyrsql/orms/sqlalchemy/errors.py +17 -0
  37. pyrsql/orms/sqlalchemy/introspection.py +107 -0
  38. pyrsql/orms/sqlalchemy/json_path.py +351 -0
  39. pyrsql/orms/sqlalchemy/orm.py +65 -0
  40. pyrsql/orms/sqlalchemy/resolver.py +202 -0
  41. pyrsql/orms/sqlalchemy/sorter.py +173 -0
  42. pyrsql/orms/sqlalchemy/statement.py +40 -0
  43. pyrsql/orms/sqlalchemy/translator.py +560 -0
  44. pyrsql/orms/sqlalchemy/type_inference.py +27 -0
  45. pyrsql/orms/sqlalchemy/types.py +64 -0
  46. pyrsql/parsing/__init__.py +32 -0
  47. pyrsql/parsing/ast.py +61 -0
  48. pyrsql/parsing/errors.py +25 -0
  49. pyrsql/parsing/lexer.py +264 -0
  50. pyrsql/parsing/limits.py +15 -0
  51. pyrsql/parsing/operators.py +201 -0
  52. pyrsql/parsing/parser.py +292 -0
  53. pyrsql/parsing/source.py +48 -0
  54. pyrsql/parsing/tokens.py +30 -0
  55. pyrsql/py.typed +0 -0
  56. pyrsql/selector/__init__.py +30 -0
  57. pyrsql/selector/analyzer.py +58 -0
  58. pyrsql/selector/ast.py +31 -0
  59. pyrsql/selector/parser.py +130 -0
  60. pyrsql/selector/semantic.py +34 -0
  61. pyrsql/semantic/__init__.py +23 -0
  62. pyrsql/semantic/analyzer.py +116 -0
  63. pyrsql/semantic/ast.py +35 -0
  64. pyrsql/semantic/errors.py +40 -0
  65. pyrsql/sorting/__init__.py +26 -0
  66. pyrsql/sorting/analyzer.py +74 -0
  67. pyrsql/sorting/ast.py +46 -0
  68. pyrsql/sorting/errors.py +21 -0
  69. pyrsql/sorting/limits.py +15 -0
  70. pyrsql/sorting/parser.py +150 -0
  71. pyrsql/sorting/semantic.py +15 -0
  72. pyrsql-0.0.1.dist-info/METADATA +72 -0
  73. pyrsql-0.0.1.dist-info/RECORD +74 -0
  74. pyrsql-0.0.1.dist-info/WHEEL +4 -0
@@ -0,0 +1,39 @@
1
+ """SQLAlchemy ORM exports."""
2
+
3
+ from pyrsql.orms.sqlalchemy.coercion import SQLAlchemyValueCoercer
4
+ from pyrsql.orms.sqlalchemy.compiled import SQLAlchemyCompiledQuery
5
+ from pyrsql.orms.sqlalchemy.compiled_page import (
6
+ SQLAlchemyCompiledPageRequest,
7
+ )
8
+ from pyrsql.orms.sqlalchemy.compiled_sort import SQLAlchemyCompiledSort
9
+ from pyrsql.orms.sqlalchemy.errors import (
10
+ SQLAlchemyJSONSupportError,
11
+ SQLAlchemyModelInspectionError,
12
+ SQLAlchemyORMError,
13
+ SQLAlchemyPathResolutionError,
14
+ )
15
+ from pyrsql.orms.sqlalchemy.introspection import SQLAlchemyModelInspector
16
+ from pyrsql.orms.sqlalchemy.json_path import (
17
+ SQLAlchemyJSONPathExpressionBuilder,
18
+ )
19
+ from pyrsql.orms.sqlalchemy.orm import SQLAlchemyORM
20
+ from pyrsql.orms.sqlalchemy.resolver import SQLAlchemyPathResolver
21
+ from pyrsql.orms.sqlalchemy.sorter import SQLAlchemySortTranslator
22
+ from pyrsql.orms.sqlalchemy.translator import SQLAlchemyExpressionTranslator
23
+
24
+ __all__ = [
25
+ "SQLAlchemyORM",
26
+ "SQLAlchemyORMError",
27
+ "SQLAlchemyCompiledPageRequest",
28
+ "SQLAlchemyCompiledQuery",
29
+ "SQLAlchemyCompiledSort",
30
+ "SQLAlchemyJSONPathExpressionBuilder",
31
+ "SQLAlchemyJSONSupportError",
32
+ "SQLAlchemyModelInspector",
33
+ "SQLAlchemyModelInspectionError",
34
+ "SQLAlchemyPathResolutionError",
35
+ "SQLAlchemyPathResolver",
36
+ "SQLAlchemySortTranslator",
37
+ "SQLAlchemyExpressionTranslator",
38
+ "SQLAlchemyValueCoercer",
39
+ ]
@@ -0,0 +1,57 @@
1
+ """SQLAlchemy ORM value coercion."""
2
+
3
+ from typing import Any
4
+
5
+ from pyrsql.core.conversion import (
6
+ FieldValueConverterSet,
7
+ ValueConversionError,
8
+ ValueConverterRegistry,
9
+ )
10
+ from pyrsql.orms.sqlalchemy.errors import SQLAlchemyORMError
11
+
12
+
13
+ class SQLAlchemyValueCoercer:
14
+ """Coerces semantic argument values using resolved Python types."""
15
+
16
+ def __init__(
17
+ self,
18
+ *,
19
+ registry: ValueConverterRegistry | None = None,
20
+ ) -> None:
21
+ self._registry = registry
22
+
23
+ def coerce(
24
+ self,
25
+ raw_value: str,
26
+ python_type: type[Any] | None,
27
+ *,
28
+ field_converter_set: FieldValueConverterSet | None = None,
29
+ model: type[Any] | None = None,
30
+ field_name: str | None = None,
31
+ field_path: str | None = None,
32
+ registry: ValueConverterRegistry | None = None,
33
+ ) -> Any:
34
+ """Coerces a raw argument into a typed Python value."""
35
+ if field_converter_set is not None:
36
+ field_converter = field_converter_set.resolve(
37
+ model=model,
38
+ field_name=field_name,
39
+ field_path=field_path,
40
+ )
41
+ if field_converter is not None:
42
+ try:
43
+ return field_converter(raw_value)
44
+ except Exception as error:
45
+ raise SQLAlchemyORMError(
46
+ f"Failed to convert {raw_value!r} for field "
47
+ f"{field_path or field_name!r}."
48
+ ) from error
49
+ active_registry = registry or self._registry
50
+ if active_registry is None:
51
+ raise SQLAlchemyORMError(
52
+ "SQLAlchemyValueCoercer requires a conversion registry."
53
+ )
54
+ try:
55
+ return active_registry.convert(raw_value, python_type)
56
+ except ValueConversionError as error:
57
+ raise SQLAlchemyORMError(str(error)) from error
@@ -0,0 +1,56 @@
1
+ """Compiled query support for SQLAlchemy."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from pyrsql.core.options import QueryOptions
6
+ from pyrsql.orms.sqlalchemy.statement import (
7
+ apply_relationship_joins,
8
+ require_sqlalchemy_select,
9
+ )
10
+ from pyrsql.orms.sqlalchemy.translator import SQLAlchemyExpressionTranslator
11
+ from pyrsql.orms.sqlalchemy.types import SQLAlchemyModel, SQLAlchemySelect
12
+ from pyrsql.semantic.ast import SemanticExpression
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class SQLAlchemyCompiledQuery:
17
+ """Compiled SQLAlchemy query plan.
18
+
19
+ Attributes:
20
+ expression: Semantic expression to translate into SQLAlchemy clauses.
21
+ options: Query configuration used during translation.
22
+ translator: Translator responsible for producing SQLAlchemy objects.
23
+ """
24
+
25
+ expression: SemanticExpression
26
+ options: QueryOptions
27
+ translator: SQLAlchemyExpressionTranslator
28
+
29
+ def apply(
30
+ self,
31
+ target: SQLAlchemySelect | object,
32
+ model: SQLAlchemyModel,
33
+ ) -> SQLAlchemySelect:
34
+ """Applies the compiled query to a SQLAlchemy Select.
35
+
36
+ Args:
37
+ target: SQLAlchemy select statement to mutate.
38
+ model: SQLAlchemy mapped class used to resolve fields.
39
+
40
+ Returns:
41
+ A SQLAlchemy select with filters and joins applied.
42
+ """
43
+ statement = require_sqlalchemy_select(target)
44
+ joins, predicate = self.translator.translate(
45
+ model,
46
+ self.expression,
47
+ options=self.options,
48
+ )
49
+ statement = apply_relationship_joins(
50
+ statement,
51
+ joins,
52
+ join_hints=self.options.join_hints,
53
+ )
54
+ if self.options.distinct:
55
+ statement = statement.distinct()
56
+ return statement.where(predicate)
@@ -0,0 +1,40 @@
1
+ """Compiled pagination support for SQLAlchemy."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from pyrsql.core.page import PageRequest
6
+ from pyrsql.orms.sqlalchemy.statement import require_sqlalchemy_select
7
+ from pyrsql.orms.sqlalchemy.types import SQLAlchemyModel, SQLAlchemySelect
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class SQLAlchemyCompiledPageRequest:
12
+ """Compiled SQLAlchemy pagination plan.
13
+
14
+ Attributes:
15
+ page_request: Pagination request to apply to a select statement.
16
+ """
17
+
18
+ page_request: PageRequest
19
+
20
+ def apply(
21
+ self,
22
+ target: SQLAlchemySelect | object,
23
+ model: SQLAlchemyModel,
24
+ ) -> SQLAlchemySelect:
25
+ """Applies the compiled page request to a SQLAlchemy Select.
26
+
27
+ Args:
28
+ target: SQLAlchemy select statement to mutate.
29
+ model: SQLAlchemy mapped class. The page application does not use
30
+ it directly, but the signature matches the other compiled
31
+ plans.
32
+
33
+ Returns:
34
+ A SQLAlchemy select with limit and offset applied.
35
+ """
36
+ del model
37
+ statement = require_sqlalchemy_select(target)
38
+ return statement.limit(self.page_request.limit).offset(
39
+ self.page_request.offset
40
+ )
@@ -0,0 +1,56 @@
1
+ """Compiled sort support for SQLAlchemy."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from pyrsql.core.options import SortOptions
6
+ from pyrsql.orms.sqlalchemy.sorter import SQLAlchemySortTranslator
7
+ from pyrsql.orms.sqlalchemy.statement import (
8
+ apply_relationship_joins,
9
+ require_sqlalchemy_select,
10
+ )
11
+ from pyrsql.orms.sqlalchemy.types import SQLAlchemyModel, SQLAlchemySelect
12
+ from pyrsql.sorting.semantic import SemanticSortField
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class SQLAlchemyCompiledSort:
17
+ """Compiled SQLAlchemy sort plan.
18
+
19
+ Attributes:
20
+ fields: Semantic sort fields to translate into order clauses.
21
+ options: Sort configuration used during translation.
22
+ translator: Translator responsible for producing SQLAlchemy objects.
23
+ """
24
+
25
+ fields: tuple[SemanticSortField, ...]
26
+ options: SortOptions
27
+ translator: SQLAlchemySortTranslator
28
+
29
+ def apply(
30
+ self,
31
+ target: SQLAlchemySelect | object,
32
+ model: SQLAlchemyModel,
33
+ ) -> SQLAlchemySelect:
34
+ """Applies the compiled sort to a SQLAlchemy Select.
35
+
36
+ Args:
37
+ target: SQLAlchemy select statement to mutate.
38
+ model: SQLAlchemy mapped class used to resolve fields.
39
+
40
+ Returns:
41
+ A SQLAlchemy select with joins and ordering applied.
42
+ """
43
+ statement = require_sqlalchemy_select(target)
44
+ joins, order_clauses = self.translator.translate(
45
+ model,
46
+ self.fields,
47
+ options=self.options,
48
+ )
49
+ statement = apply_relationship_joins(
50
+ statement,
51
+ joins,
52
+ join_hints=self.options.join_hints,
53
+ )
54
+ if not order_clauses:
55
+ return statement
56
+ return statement.order_by(*order_clauses)
@@ -0,0 +1,25 @@
1
+ """Custom predicate extension points for the SQLAlchemy orm."""
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ from sqlalchemy.sql.elements import ColumnElement
8
+
9
+ from pyrsql.core.options import QueryOptions
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class SQLAlchemyCustomPredicateInput:
14
+ """Structured input for ORM-specific custom predicate builders."""
15
+
16
+ expression: ColumnElement[Any]
17
+ python_type: type[Any] | None
18
+ values: tuple[Any, ...]
19
+ options: QueryOptions
20
+
21
+
22
+ SQLAlchemyCustomPredicate = Callable[
23
+ [SQLAlchemyCustomPredicateInput],
24
+ ColumnElement[bool],
25
+ ]
@@ -0,0 +1,17 @@
1
+ """SQLAlchemy ORM-specific exceptions."""
2
+
3
+
4
+ class SQLAlchemyORMError(ValueError):
5
+ """Base exception for SQLAlchemy ORM failures."""
6
+
7
+
8
+ class SQLAlchemyModelInspectionError(SQLAlchemyORMError):
9
+ """Raised when a model cannot be inspected as a mapped class."""
10
+
11
+
12
+ class SQLAlchemyPathResolutionError(SQLAlchemyORMError):
13
+ """Raised when a semantic field path cannot be resolved."""
14
+
15
+
16
+ class SQLAlchemyJSONSupportError(SQLAlchemyORMError):
17
+ """Raised when JSON/JSONB translation cannot be completed."""
@@ -0,0 +1,107 @@
1
+ """SQLAlchemy ORM inspection helpers."""
2
+
3
+ from typing import Any
4
+
5
+ from sqlalchemy import inspect
6
+ from sqlalchemy.exc import NoInspectionAvailable
7
+ from sqlalchemy.orm import Mapper
8
+ from sqlalchemy.orm.properties import ColumnProperty
9
+ from sqlalchemy.sql.sqltypes import JSON
10
+
11
+ from pyrsql.orms.sqlalchemy.errors import SQLAlchemyModelInspectionError
12
+ from pyrsql.orms.sqlalchemy.types import (
13
+ SQLAlchemyAttributeKind,
14
+ SQLAlchemyMappedAttribute,
15
+ )
16
+
17
+
18
+ class SQLAlchemyModelInspector:
19
+ """Provides stable access to public SQLAlchemy ORM inspection APIs."""
20
+
21
+ def __init__(self) -> None:
22
+ self._mapper_cache: dict[type[Any], Mapper[Any]] = {}
23
+ self._attribute_cache: dict[
24
+ tuple[type[Any], str], SQLAlchemyMappedAttribute
25
+ ] = {}
26
+
27
+ def inspect_model(self, model: type[Any]) -> Mapper[Any]:
28
+ """Returns the ORM mapper for a mapped class."""
29
+ cached_mapper = self._mapper_cache.get(model)
30
+ if cached_mapper is not None:
31
+ return cached_mapper
32
+ try:
33
+ mapper = inspect(model)
34
+ except NoInspectionAvailable as error:
35
+ raise SQLAlchemyModelInspectionError(
36
+ f"Type {model!r} is not a SQLAlchemy mapped class."
37
+ ) from error
38
+ if not isinstance(mapper, Mapper):
39
+ raise SQLAlchemyModelInspectionError(
40
+ f"Type {model!r} did not resolve to a SQLAlchemy ORM mapper."
41
+ )
42
+ self._mapper_cache[model] = mapper
43
+ return mapper
44
+
45
+ def get_mapped_attribute(
46
+ self,
47
+ model: type[Any],
48
+ attribute_name: str,
49
+ ) -> SQLAlchemyMappedAttribute:
50
+ """Returns metadata for a mapped attribute by name."""
51
+ cache_key = (model, attribute_name)
52
+ cached_attribute = self._attribute_cache.get(cache_key)
53
+ if cached_attribute is not None:
54
+ return cached_attribute
55
+
56
+ mapper = self.inspect_model(model)
57
+ if attribute_name in mapper.relationships:
58
+ relationship = mapper.relationships[attribute_name]
59
+ mapped_attribute = SQLAlchemyMappedAttribute(
60
+ name=attribute_name,
61
+ kind=SQLAlchemyAttributeKind.RELATIONSHIP,
62
+ owner_model=model,
63
+ attribute=getattr(model, attribute_name),
64
+ mapper=relationship.mapper,
65
+ python_type=relationship.mapper.class_,
66
+ is_collection=bool(relationship.uselist),
67
+ )
68
+ self._attribute_cache[cache_key] = mapped_attribute
69
+ return mapped_attribute
70
+ if attribute_name in mapper.column_attrs:
71
+ column_property = mapper.column_attrs[attribute_name]
72
+ mapped_attribute = SQLAlchemyMappedAttribute(
73
+ name=attribute_name,
74
+ kind=SQLAlchemyAttributeKind.COLUMN,
75
+ owner_model=model,
76
+ attribute=getattr(model, attribute_name),
77
+ mapper=None,
78
+ python_type=self._resolve_column_python_type(column_property),
79
+ is_json=self._is_json_column(column_property),
80
+ )
81
+ self._attribute_cache[cache_key] = mapped_attribute
82
+ return mapped_attribute
83
+ raise SQLAlchemyModelInspectionError(
84
+ f"Attribute {attribute_name!r} is not mapped on model "
85
+ f"{model.__name__!r}."
86
+ )
87
+
88
+ @staticmethod
89
+ def _resolve_column_python_type(
90
+ column_property: ColumnProperty[Any],
91
+ ) -> type[Any] | None:
92
+ """Returns the Python type for a column property when available."""
93
+ if not column_property.columns:
94
+ return None
95
+ try:
96
+ return column_property.columns[0].type.python_type
97
+ except (AttributeError, NotImplementedError):
98
+ return None
99
+
100
+ @staticmethod
101
+ def _is_json_column(
102
+ column_property: ColumnProperty[Any],
103
+ ) -> bool:
104
+ """Returns whether the column property stores JSON-like data."""
105
+ if not column_property.columns:
106
+ return False
107
+ return isinstance(column_property.columns[0].type, JSON)