pythonboot-sqlalchemy 0.1.0__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.
@@ -0,0 +1,13 @@
1
+ """Public settings and native Session factory names for SQLAlchemy integration."""
2
+
3
+ from pythonboot_sqlalchemy.pagination import Page, PageQuery, apaginate, paginate
4
+ from pythonboot_sqlalchemy.factories import AsyncSessionFactory, SessionFactory
5
+ from pythonboot_sqlalchemy.properties import (
6
+ DataSourceProperties, PoolProperties, SessionProperties, SqlAlchemyProperties,
7
+ )
8
+
9
+ __all__ = [
10
+ "PageQuery", "Page", "paginate", "apaginate",
11
+ "AsyncSessionFactory", "SessionFactory", "DataSourceProperties", "PoolProperties",
12
+ "SessionProperties", "SqlAlchemyProperties",
13
+ ]
@@ -0,0 +1,77 @@
1
+ """Register native engines and session factories without implicit commits."""
2
+
3
+ import logging
4
+
5
+ from sqlalchemy import Engine, create_engine
6
+ from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
7
+
8
+ from pythonboot import (
9
+ auto_configuration, bean, conditional_on_missing_bean, conditional_on_property,
10
+ )
11
+ from pythonboot_sqlalchemy.engine import database_url, engine_options
12
+ from pythonboot_sqlalchemy.factories import AsyncSessionFactory, SessionFactory
13
+ from pythonboot_sqlalchemy.properties import DataSourceProperties, SqlAlchemyProperties
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @auto_configuration(order=1900, imports=(DataSourceProperties, SqlAlchemyProperties))
19
+ @conditional_on_property("pythonboot.sqlalchemy.enabled", having_value=True, match_if_missing=True)
20
+ class SqlAlchemyPropertiesAutoConfiguration:
21
+ """Bind connection and ORM properties before resource auto-configuration."""
22
+
23
+
24
+ @auto_configuration(order=2000)
25
+ @conditional_on_property("pythonboot.sqlalchemy.enabled", having_value=True, match_if_missing=True)
26
+ @conditional_on_property("pythonboot.sqlalchemy.mode", having_value="sync")
27
+ class SqlAlchemySyncEngineAutoConfiguration:
28
+ """Provide an application-owned synchronous engine unless one already exists."""
29
+
30
+ @bean(name="sqlAlchemyEngine", destroy_method="dispose")
31
+ @conditional_on_missing_bean(Engine, "sqlAlchemyEngine")
32
+ def engine(self, properties: DataSourceProperties, orm: SqlAlchemyProperties) -> Engine:
33
+ if not orm.enabled or orm.mode != "sync":
34
+ raise ValueError("SQLAlchemy enabled and mode must be selected in application YAML")
35
+ logger.info("Creating synchronous SQLAlchemy engine with pool_kind=%s.", properties.pool.kind)
36
+ return create_engine(database_url(properties), **engine_options(properties, asynchronous=False))
37
+
38
+
39
+ @auto_configuration(order=2000)
40
+ @conditional_on_property("pythonboot.sqlalchemy.enabled", having_value=True, match_if_missing=True)
41
+ @conditional_on_property("pythonboot.sqlalchemy.mode", having_value="async", match_if_missing=True)
42
+ class SqlAlchemyAsyncEngineAutoConfiguration:
43
+ """Provide an application-owned async engine with awaited disposal on shutdown."""
44
+
45
+ @bean(name="sqlAlchemyAsyncEngine", destroy_method="dispose")
46
+ @conditional_on_missing_bean(AsyncEngine, "sqlAlchemyAsyncEngine")
47
+ def engine(self, properties: DataSourceProperties, orm: SqlAlchemyProperties) -> AsyncEngine:
48
+ if not orm.enabled or orm.mode != "async":
49
+ raise ValueError("SQLAlchemy enabled and mode must be selected in application YAML")
50
+ logger.info("Creating asynchronous SQLAlchemy engine with pool_kind=%s.", properties.pool.kind)
51
+ return create_async_engine(database_url(properties), **engine_options(properties, asynchronous=True))
52
+
53
+
54
+ @auto_configuration(order=2100)
55
+ @conditional_on_property("pythonboot.sqlalchemy.enabled", having_value=True, match_if_missing=True)
56
+ @conditional_on_property("pythonboot.sqlalchemy.mode", having_value="sync")
57
+ class SqlAlchemySyncSessionAutoConfiguration:
58
+ """Create a native singleton factory; sessions remain explicitly scoped by callers."""
59
+
60
+ @bean(name="sqlAlchemySessionFactory")
61
+ @conditional_on_missing_bean(SessionFactory, "sqlAlchemySessionFactory")
62
+ def session_factory(self, engine: Engine, properties: SqlAlchemyProperties) -> SessionFactory:
63
+ logger.info("Creating synchronous SQLAlchemy session factory.")
64
+ return SessionFactory(bind=engine, **properties.session.model_dump())
65
+
66
+
67
+ @auto_configuration(order=2100)
68
+ @conditional_on_property("pythonboot.sqlalchemy.enabled", having_value=True, match_if_missing=True)
69
+ @conditional_on_property("pythonboot.sqlalchemy.mode", having_value="async", match_if_missing=True)
70
+ class SqlAlchemyAsyncSessionAutoConfiguration:
71
+ """Create a native async factory without sharing sessions across tasks."""
72
+
73
+ @bean(name="sqlAlchemyAsyncSessionFactory")
74
+ @conditional_on_missing_bean(AsyncSessionFactory, "sqlAlchemyAsyncSessionFactory")
75
+ def session_factory(self, engine: AsyncEngine, properties: SqlAlchemyProperties) -> AsyncSessionFactory:
76
+ logger.info("Creating asynchronous SQLAlchemy session factory.")
77
+ return AsyncSessionFactory(bind=engine, **properties.session.model_dump())
@@ -0,0 +1,43 @@
1
+ """Build native engine options while preserving dialect and driver selection."""
2
+
3
+ from typing import Any
4
+
5
+ from sqlalchemy.pool import AsyncAdaptedQueuePool, NullPool, QueuePool, StaticPool
6
+
7
+ from pythonboot_sqlalchemy.properties import DataSourceProperties
8
+
9
+
10
+ def engine_options(properties: DataSourceProperties, *, asynchronous: bool) -> dict[str, Any]:
11
+ """Translate declared pool settings without guessing the database driver."""
12
+ options: dict[str, Any] = {
13
+ "echo": properties.echo,
14
+ "hide_parameters": properties.hide_parameters,
15
+ "connect_args": dict(properties.connect_args),
16
+ "pool_pre_ping": properties.pool.pre_ping,
17
+ }
18
+ if properties.isolation_level is not None:
19
+ options["isolation_level"] = properties.isolation_level
20
+ pool = properties.pool
21
+ if pool.kind == "queue":
22
+ options["poolclass"] = AsyncAdaptedQueuePool if asynchronous else QueuePool
23
+ elif pool.kind == "null":
24
+ options["poolclass"] = NullPool
25
+ elif pool.kind == "static":
26
+ options["poolclass"] = StaticPool
27
+ for name, value in (
28
+ ("pool_size", pool.size),
29
+ ("max_overflow", pool.max_overflow),
30
+ ("pool_timeout", pool.timeout),
31
+ ("pool_recycle", pool.recycle),
32
+ ):
33
+ if value is not None:
34
+ options[name] = value
35
+ return options
36
+
37
+
38
+ def database_url(properties: DataSourceProperties) -> str:
39
+ """Require a URL only when the default engine is actually created."""
40
+ url = properties.url.get_secret_value()
41
+ if not url.strip():
42
+ raise ValueError("pythonboot.datasource.url is required for the default SQLAlchemy engine")
43
+ return url
@@ -0,0 +1,9 @@
1
+ """Short names for native session factory classes, compatible with Bean injection."""
2
+
3
+ from sqlalchemy.ext.asyncio import async_sessionmaker
4
+ from sqlalchemy.orm import sessionmaker
5
+
6
+ SessionFactory = sessionmaker
7
+ AsyncSessionFactory = async_sessionmaker
8
+
9
+ __all__ = ["SessionFactory", "AsyncSessionFactory"]
@@ -0,0 +1,100 @@
1
+ """Validated pagination models and native synchronous/asynchronous query helpers."""
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any, Generic, TypeVar, overload
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+ from sqlalchemy import Select, func, select
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+ from sqlalchemy.orm import Session
10
+
11
+ T = TypeVar("T")
12
+ R = TypeVar("R")
13
+
14
+
15
+ class PageQuery(BaseModel):
16
+ """One-based pagination request with a default page size of ten."""
17
+
18
+ model_config = ConfigDict(validate_assignment=True)
19
+
20
+ page: int = Field(default=1, ge=1)
21
+ pageSize: int = Field(default=10, ge=1)
22
+
23
+
24
+ class Page(PageQuery, Generic[T]):
25
+ """Materialized page containing the matching row count and current rows."""
26
+
27
+ model_config = ConfigDict(validate_assignment=True, arbitrary_types_allowed=True)
28
+
29
+ total: int = Field(default=0, ge=0)
30
+ rows: list[T] = Field(default_factory=list)
31
+
32
+
33
+ def _queries(statement: Select[Any], query: PageQuery) -> tuple[Select[Any], Select[Any]]:
34
+ """Count the full selection and apply the requested window without mutating it."""
35
+ if not isinstance(statement, Select):
36
+ raise TypeError("Pagination requires a SQLAlchemy Select statement")
37
+ if len(statement.column_descriptions) != 1:
38
+ raise ValueError("Pagination requires exactly one selected entity or scalar column")
39
+ unpaged = statement.limit(None).offset(None).fetch(None)
40
+ count = select(func.count()).select_from(unpaged.order_by(None).subquery())
41
+ rows = unpaged.offset((query.page - 1) * query.pageSize).limit(query.pageSize)
42
+ return count, rows
43
+
44
+
45
+ @overload
46
+ def paginate(
47
+ session: Session, statement: Select[tuple[T]], query: PageQuery | None = None,
48
+ *, mapper: None = None,
49
+ ) -> Page[T]: ...
50
+
51
+
52
+ @overload
53
+ def paginate(
54
+ session: Session, statement: Select[tuple[T]], query: PageQuery | None = None,
55
+ *, mapper: Callable[[T], R],
56
+ ) -> Page[R]: ...
57
+
58
+
59
+ def paginate(
60
+ session: Session, statement: Select[Any], query: PageQuery | None = None,
61
+ *, mapper: Callable[[Any], Any] | None = None,
62
+ ) -> Page[Any]:
63
+ """Execute count and rows in the caller's Session without committing or closing it."""
64
+ options = PageQuery.model_validate(query.model_dump() if query is not None else {})
65
+ count, rows_query = _queries(statement, options)
66
+ total = session.scalar(count)
67
+ rows = session.scalars(rows_query).all()
68
+ return Page(
69
+ total=total, page=options.page, pageSize=options.pageSize,
70
+ rows=[mapper(row) for row in rows] if mapper is not None else list(rows),
71
+ )
72
+
73
+
74
+ @overload
75
+ async def apaginate(
76
+ session: AsyncSession, statement: Select[tuple[T]], query: PageQuery | None = None,
77
+ *, mapper: None = None,
78
+ ) -> Page[T]: ...
79
+
80
+
81
+ @overload
82
+ async def apaginate(
83
+ session: AsyncSession, statement: Select[tuple[T]], query: PageQuery | None = None,
84
+ *, mapper: Callable[[T], R],
85
+ ) -> Page[R]: ...
86
+
87
+
88
+ async def apaginate(
89
+ session: AsyncSession, statement: Select[Any], query: PageQuery | None = None,
90
+ *, mapper: Callable[[Any], Any] | None = None,
91
+ ) -> Page[Any]:
92
+ """Await count and rows in the caller's AsyncSession without transaction ownership."""
93
+ options = PageQuery.model_validate(query.model_dump() if query is not None else {})
94
+ count, rows_query = _queries(statement, options)
95
+ total = await session.scalar(count)
96
+ rows = (await session.scalars(rows_query)).all()
97
+ return Page(
98
+ total=total, page=options.page, pageSize=options.pageSize,
99
+ rows=[mapper(row) for row in rows] if mapper is not None else list(rows),
100
+ )
@@ -0,0 +1,67 @@
1
+ """Typed database and ORM settings bound through PythonBoot configuration properties."""
2
+
3
+ from typing import Any, Literal
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator
6
+ from pydantic_settings import BaseSettings, SettingsConfigDict
7
+
8
+ from pythonboot import configuration_properties
9
+
10
+
11
+ class PoolProperties(BaseModel):
12
+ """Pool options; omitted sizing values retain dialect-specific defaults."""
13
+
14
+ model_config = ConfigDict(extra="forbid")
15
+
16
+ kind: Literal["default", "queue", "null", "static"] = "default"
17
+ size: int | None = Field(default=None, ge=1)
18
+ max_overflow: int | None = Field(default=None, ge=-1)
19
+ timeout: float | None = Field(default=None, ge=0, allow_inf_nan=False)
20
+ recycle: int | None = Field(default=None, ge=-1)
21
+ pre_ping: bool = True
22
+
23
+ @model_validator(mode="after")
24
+ def validate_sizing(self) -> "PoolProperties":
25
+ if self.kind in ("null", "static") and any(
26
+ value is not None for value in (self.size, self.max_overflow, self.timeout)
27
+ ):
28
+ raise ValueError("Pool sizing options require a queue-compatible pool")
29
+ return self
30
+
31
+
32
+ @configuration_properties("pythonboot.datasource")
33
+ class DataSourceProperties(BaseSettings):
34
+ """Connection settings shared by default synchronous and asynchronous engines."""
35
+
36
+ model_config = SettingsConfigDict(
37
+ env_prefix="PYTHONBOOT_DATASOURCE_", env_nested_delimiter="__", extra="forbid",
38
+ )
39
+
40
+ url: SecretStr = SecretStr("")
41
+ echo: bool = False
42
+ hide_parameters: bool = True
43
+ isolation_level: str | None = None
44
+ connect_args: dict[str, Any] = Field(default_factory=dict)
45
+ pool: PoolProperties = Field(default_factory=PoolProperties)
46
+
47
+
48
+ class SessionProperties(BaseModel):
49
+ """Native sessionmaker options without automatic transaction boundaries."""
50
+
51
+ model_config = ConfigDict(extra="forbid")
52
+
53
+ expire_on_commit: bool = False
54
+ autoflush: bool = True
55
+
56
+
57
+ @configuration_properties("pythonboot.sqlalchemy")
58
+ class SqlAlchemyProperties(BaseSettings):
59
+ """ORM settings; mode and enabled select auto-configurations through YAML."""
60
+
61
+ model_config = SettingsConfigDict(
62
+ env_prefix="PYTHONBOOT_SQLALCHEMY_", env_nested_delimiter="__", extra="forbid",
63
+ )
64
+
65
+ enabled: bool = True
66
+ mode: Literal["sync", "async"] = "async"
67
+ session: SessionProperties = Field(default_factory=SessionProperties)
@@ -0,0 +1,172 @@
1
+ Metadata-Version: 2.5
2
+ Name: pythonboot-sqlalchemy
3
+ Version: 0.1.0
4
+ Summary: Native SQLAlchemy ORM configuration and lifecycle integration for PythonBoot
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: pydantic-settings<3,>=2.7
7
+ Requires-Dist: pydantic<3,>=2.10
8
+ Requires-Dist: pythonboot<0.2,>=0.1.0
9
+ Requires-Dist: sqlalchemy[asyncio]<3,>=2.0.38
10
+ Description-Content-Type: text/markdown
11
+
12
+ # pythonboot-sqlalchemy
13
+
14
+ PythonBoot 的 SQLAlchemy 原生 ORM 实现包。分发名为 `pythonboot-sqlalchemy`,
15
+ 导入名为 `pythonboot_sqlalchemy`;应用推荐安装聚合包 `pythonboot-starter-sqlalchemy`。
16
+
17
+ - 通过 Configuration Properties 绑定 `pythonboot.datasource` 和 `pythonboot.sqlalchemy`。
18
+ - 自动注册同步或异步 Engine 和原生 Session 工厂,应用 Bean 优先。
19
+ - `SessionFactory` 是 `sessionmaker` 的类别名,`AsyncSessionFactory` 是 `async_sessionmaker`
20
+ 的类别名,可直接作为 `autowired()` 注入类型,不增加代理或包装层。
21
+ - Session 由业务创建并关闭;业务显式 `commit()`,需要继续复用失败 Session 时显式 `rollback()`。
22
+ - 不提供 Mapper、自动提交、事务拦截器、自动建表或数据库迁移。
23
+ - 默认 Engine 关闭时调用 `dispose()`;异步关闭由容器等待。
24
+ - 数据库驱动由应用安装,URL 交由 SQLAlchemy 原生解析;未默认安装任何驱动。
25
+
26
+ 不使用 `@transactional` 管理 Session,是为了让 `commit()` 的位置直接出现在业务流程中,
27
+ 便于看清和管理事务边界、提交时机及异常处理。在 AI 辅助编写代码的工作流中,
28
+ 明确要求生成 Session 作用域和提交逻辑可以降低遗漏提交的概率,显式调用也便于审阅。
29
+
30
+ ```yaml
31
+ pythonboot:
32
+ datasource:
33
+ url: postgresql+asyncpg://user:password@localhost:5432/app
34
+ sqlalchemy:
35
+ mode: async
36
+ session:
37
+ expire_on_commit: false
38
+ autoflush: true
39
+ ```
40
+
41
+ ```python
42
+ from pythonboot import autowired, service
43
+ from pythonboot_sqlalchemy import AsyncSessionFactory
44
+
45
+ @service
46
+ class UserService:
47
+ session_factory: AsyncSessionFactory = autowired()
48
+
49
+ async def rename(self, user_id: int, name: str) -> None:
50
+ async with self.session_factory() as session:
51
+ user = await session.get(User, user_id)
52
+ if user is None:
53
+ raise ValueError("User not found")
54
+ user.name = name
55
+ await session.commit()
56
+ ```
57
+
58
+ `User` 为应用自定义 SQLAlchemy ORM 模型。多 Service 共享事务时显式传递 Session。
59
+ 连接池 Starter 可以提供自己的 Engine Bean(外部连接池适配通常配合 `NullPool`),
60
+ 或注册更早装配的自动配置;本模块退让并继续使用原生 Session 工厂。
61
+ 用户 Engine 的关闭责任由其 Bean 生命周期声明,外部池由创建方关闭。
62
+
63
+ 完整配置、公共 API、同步用法和多数据源说明见
64
+ [`viteDocs/docs/zh/sqlalchemy.md`](../../viteDocs/docs/zh/sqlalchemy.md),
65
+ 可运行用法示例见 [`examples/sqlalchemy`](../../examples/sqlalchemy/README.md)。
66
+
67
+ ## English
68
+
69
+ `pythonboot-sqlalchemy` is the implementation distribution; import `pythonboot_sqlalchemy`.
70
+ Install `pythonboot-starter-sqlalchemy` plus your chosen database driver in applications.
71
+ Configuration Properties bind connection and ORM settings. Native Engine and Session factory
72
+ Beans back off to application Beans. Callers explicitly create Sessions and commit changes;
73
+ Session context exit closes resources and rolls back uncommitted transactions.
74
+ There is no Mapper, implicit commit, transaction interceptor, schema creation, or migration.
75
+ Explicit commit points make transaction boundaries and failure handling visible and easier to manage.
76
+ In AI-assisted development, requiring Session scopes and commit logic reduces omissions and makes
77
+ the resulting transaction flow easier to review. This Starter does not use `@transactional` to manage Sessions.
78
+ Short factory names are aliases of native classes, compatible with concrete-class Bean injection.
79
+ The container disposes default engines, awaiting asynchronous disposal. Custom Engine and
80
+ external pool ownership must be declared by their providers.
81
+ See [English documentation](../../viteDocs/docs/en/sqlalchemy.md) and the
82
+ [example](../../examples/sqlalchemy/README.md).
83
+
84
+
85
+ 完整分层应用见 [全量 Starter 示例](../../example/README.md),包含全部 Starter、PostgreSQL/Redis 和同步/异步 HTTP 用法。
86
+ See the [complete Starter application](../../example/README.md) for all Starters and sync/async HTTP examples.
87
+
88
+
89
+ ## 分页查询
90
+
91
+ `PageQuery` 包含 `page`(默认 1)和 `pageSize`(默认 10),二者必须大于等于 1。
92
+ `Page[T]` 包含相同分页参数,以及 `total`(默认 0)和 `rows: list[T]`(默认空列表)。
93
+ Python 属性与 JSON 字段均使用 `pageSize`,不需要别名转换。
94
+
95
+ 从 `pythonboot_sqlalchemy` 或 `pythonboot_starter_sqlalchemy` 导入这两个模型,以及
96
+ 同步 `paginate`、异步 `apaginate`。实现位于实现模块,Starter 只导出公共入口,无新增依赖。
97
+
98
+ ```python
99
+ from sqlalchemy import select
100
+ from pythonboot_starter_sqlalchemy import Page, PageQuery, paginate, apaginate
101
+
102
+ query = PageQuery(page=1, pageSize=10)
103
+ statement = select(User).where(User.name == "Alice").order_by(User.id)
104
+
105
+ with session_factory() as session:
106
+ page = paginate(session, statement, query, mapper=UserView.model_validate)
107
+
108
+ async with async_session_factory() as session:
109
+ page = await apaginate(session, statement, query, mapper=UserView.model_validate)
110
+ ```
111
+
112
+ 省略 `query` 时使用默认分页;`mapper` 可选,用于在 Session 内将实体转换成 DTO。
113
+ 省略 `mapper` 则 `rows` 保留原生实体或标量;调用方应在关闭 Session 前加载所需属性。
114
+
115
+ 封装对传入 Select 生成 COUNT 子查询和 OFFSET/LIMIT 查询,保留过滤、JOIN、DISTINCT、
116
+ GROUP BY 与 HAVING;总数查询移除排序,数据查询保留原排序。原有 LIMIT/OFFSET/FETCH
117
+ 由分页参数替换,原 Select 不被修改。无匹配结果或页码超出范围时返回空 `rows`,
118
+ `total` 始终表示完整筛选结果总数,不自动调整页码。请使用包含唯一字段的排序保证分页稳定。
119
+
120
+ 支持只选择一个 ORM 实体或一个标量列的 Select;多列/多实体查询明确报错。
121
+ 总数按 SQL 查询结果行统计,JOIN 重复行不自动去重;需要去重时显式使用 DISTINCT。
122
+ 需要 `.unique()` 的集合 joined eager loading 不在此封装范围内,可使用 `selectinload`。
123
+ COUNT 与数据查询是两次顺序执行的 SQL,在并发写入时一致性取决于调用方事务隔离级别。
124
+ 封装不创建、关闭、提交或回滚 Session,不修改事务边界,异步操作不得并发共享同一 Session。
125
+
126
+ 全量示例提供 `GET /users/page` 和 `GET /sync/users/page`,支持 `page`、`pageSize`、`name` 参数,
127
+ 返回 `BaseResult[Page[UserView]]`;原有列表接口保持不变。
128
+
129
+
130
+ ## Pagination
131
+
132
+ `PageQuery` has `page` (default 1) and `pageSize` (default 10), both at least 1.
133
+ `Page[T]` adds `total` (default 0) and `rows: list[T]` (an independent empty list by default).
134
+ Python attributes and JSON use the exact field name `pageSize`.
135
+
136
+ Import both models and the sync `paginate` / async `apaginate` helpers from
137
+ `pythonboot_sqlalchemy` or `pythonboot_starter_sqlalchemy`. Implementation belongs to the module;
138
+ the Starter only re-exports public interfaces. No dependency is added.
139
+
140
+ ```python
141
+ from sqlalchemy import select
142
+ from pythonboot_starter_sqlalchemy import Page, PageQuery, paginate, apaginate
143
+
144
+ query = PageQuery(page=1, pageSize=10)
145
+ statement = select(User).where(User.name == "Alice").order_by(User.id)
146
+
147
+ with session_factory() as session:
148
+ page = paginate(session, statement, query, mapper=UserView.model_validate)
149
+
150
+ async with async_session_factory() as session:
151
+ page = await apaginate(session, statement, query, mapper=UserView.model_validate)
152
+ ```
153
+
154
+ Omit `query` to use defaults. The optional `mapper` materializes DTOs within the Session;
155
+ otherwise rows retain native entities or scalar values. Load required attributes before closing.
156
+
157
+ A COUNT subquery measures the full selection, and an OFFSET/LIMIT query loads the page.
158
+ Filters, joins, DISTINCT, GROUP BY, and HAVING are preserved. Count removes ordering; rows retain it.
159
+ Existing LIMIT/OFFSET/FETCH clauses are replaced by pagination. The original Select is not mutated.
160
+ Empty and out-of-range pages keep the full matching `total`, return empty `rows`, and retain the
161
+ requested page number. Order by a unique key for stable paging.
162
+
163
+ Select exactly one ORM entity or scalar column; multi-column/entity statements raise an error.
164
+ Totals count SQL result rows, including duplicate join rows; use explicit DISTINCT when needed.
165
+ Collection joined eager loading requiring `.unique()` is outside this helper's scope; use `selectinload`.
166
+ Count and rows execute sequentially as two queries; concurrent-write consistency depends on the
167
+ caller's transaction isolation. Helpers do not create, close, commit, or roll back Sessions.
168
+ Never share an AsyncSession between concurrent tasks.
169
+
170
+ The complete application exposes `GET /users/page` and `GET /sync/users/page`, accepting
171
+ `page`, `pageSize`, and optional `name`, and returning `BaseResult[Page[UserView]]`.
172
+ Existing list endpoints remain available.
@@ -0,0 +1,10 @@
1
+ pythonboot_sqlalchemy/__init__.py,sha256=0JEo8XBhcrVDgutC690nsMyZmh8pHt6Tz_mIX2wfNhA,579
2
+ pythonboot_sqlalchemy/configuration.py,sha256=WOixEYQ8-b6YiZjVa7V3d9G7hCUN3ZSfdwJ2rzU0Zrc,4264
3
+ pythonboot_sqlalchemy/engine.py,sha256=2l14Dc6pP0ZOF0sEBqQJmkz_xx_hqDCaBUaU3Po6rMw,1653
4
+ pythonboot_sqlalchemy/factories.py,sha256=SE3hF7MwbihVD6zGugRQrW6Qd0SjkUAWBzUpQyY2wwI,306
5
+ pythonboot_sqlalchemy/pagination.py,sha256=11Op-AIy7OxtsCIzPNff2uQjNiLnCcuahrjYR8Fknkc,3578
6
+ pythonboot_sqlalchemy/properties.py,sha256=EAa3ePjgcZlfJCuLw4otif9Hmt6rvV5youC76Vrvsg8,2405
7
+ pythonboot_sqlalchemy-0.1.0.dist-info/METADATA,sha256=-fBYKMD7QL0Gh0HgobThLOgFL6CeqOgi6PxQa7JSavI,9331
8
+ pythonboot_sqlalchemy-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ pythonboot_sqlalchemy-0.1.0.dist-info/entry_points.txt,sha256=GSuptGaH4dy7RYAhKyxza4mz_ISScR48p09DVVjSHy0,76
10
+ pythonboot_sqlalchemy-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pythonboot.autoconfigure]
2
+ sqlalchemy = pythonboot_sqlalchemy.configuration