From 9d9cff88f67c5f088d4e49c7551491cfac29122d Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 12 Nov 2026 00:37:17 +0800 Subject: [PATCH 1/2] feat(model)!: one declaration surface per fact, declared by accessor A model used to declare the same fact twice: once as a class-body dunder and once as the classmethod that read it. Some consumers obeyed an overridden method, the rest read the dunder past it, and nothing said which was authoritative -- so an override could work in one code path and be silently ignored in another. This makes the accessor the declaration. table_name(), schema_name(), primary_key(), pk_auto_generated(), the four locators (deleted_at_field / created_at_field / updated_at_field / version_field), version_increment_by(), read_only(), logger_name(), no_track_fields() and uuid_version() are now declared by overriding them, and their defaults read no dunder at all. table_name() has no default: a model that does not declare one fails with NotImplementedError at the first read instead of carrying a None table name into a statement. The field-level surface gains composite declarations: UseIndex takes an authoritative columns list (the annotated field must be its leftmost column), UseConstraint gains the multi-column form for branches with a table-level counterpart, and index names are uniqueness-checked across both declaration surfaces at class creation. UsePartitionKey adds the partition key as an annotation, extracted in field order, with partition_key_columns() mirroring primary_key_columns(); the direct __partition_key__ tuple remains the entry point for introspected models and declaring both is a TypeError. Three additions round out the declaration story: uuid_version() selects time-ordered uuid7 keys (probed, not imported, since the stdlib gained it in 3.14), natural_key() lets find_one_or_create take its key from defaults, and reload() is the other half of the stale-record recipe -- re-read into the same instance and re-point the optimistic lock so a retry is a retry rather than a second conflict. The soft-delete and partial-unique-index recipe is documented and pinned by a round trip. Breaking, and deliberately so: there is no compatibility layer. Every model in this repository, the testsuite it drives, and nine backend repositories were migrated by an AST-located sweep (kept under scripts/) in the same change, so the family is consistent at this revision. The rules page .claude/rules/declaration-surfaces.md states the policy the code now implements. --- .claude/architecture.md | 14 +- .claude/code_style.md | 10 +- .claude/commands/gen-model.md | 9 +- .claude/commands/gen-relation.md | 27 +- .claude/commands/validate-model.md | 10 +- .claude/field-proxy-comprehensive-guide.md | 5 +- .claude/rules/README.md | 1 + .claude/rules/constants.md | 5 + .claude/rules/declaration-surfaces.md | 187 +++++++++ .../skills/dev-testing-contributor/SKILL.md | 35 +- .../skills/user-activerecord-pattern/SKILL.md | 24 +- .../skills/user-enterprise-features/SKILL.md | 40 +- .claude/skills/user-getting-started/SKILL.md | 4 +- .claude/skills/user-modeling-guide/SKILL.md | 60 ++- .../skills/user-performance-tuning/SKILL.md | 16 +- .claude/skills/user-relationships/SKILL.md | 32 +- .claude/skills/user-troubleshooting/SKILL.md | 90 ++++- .github/workflows/test.yml | 10 +- docs/LLM_CONTEXT.md | 20 +- .../backend_specific_features/dialect.md | 5 +- docs/en_US/connection/connection_pool.md | 10 +- docs/en_US/getting_started/installation.md | 5 +- .../introduction/coming_from_frameworks.md | 5 +- .../competitor_analysis/django_orm.md | 15 +- .../competitor_analysis/peewee.md | 15 +- .../competitor_analysis/prisma.md | 10 +- .../competitor_analysis/sqlalchemy.md | 5 +- .../competitor_analysis/sqlmodel.md | 10 +- .../competitor_analysis/tortoise_orm.md | 10 +- docs/en_US/logging/README.md | 10 +- docs/en_US/logging/data_summarization.md | 5 +- docs/en_US/logging/per_logger_config.md | 10 +- docs/en_US/modeling/batch_processing.md | 5 +- docs/en_US/modeling/best_practices.md | 24 +- docs/en_US/modeling/ddl_source.md | 23 +- docs/en_US/modeling/ddl_source_examples.md | 12 +- docs/en_US/modeling/derived_fields.md | 10 +- docs/en_US/modeling/fields.md | 9 +- docs/en_US/modeling/keyless_models.md | 24 +- docs/en_US/modeling/mixins.md | 31 +- docs/en_US/modeling/readonly_models.md | 20 +- docs/en_US/querying/cheatsheet.md | 5 +- docs/en_US/relationships/definitions.md | 8 +- docs/en_US/relationships/eager_loading.md | 10 +- docs/en_US/testing/backend_testing.md | 5 +- docs/en_US/testing/dummy.md | 5 +- docs/zh_CN/connection/connection_pool.md | 10 +- docs/zh_CN/getting_started/installation.md | 5 +- .../introduction/coming_from_frameworks.md | 5 +- .../competitor_analysis/django_orm.md | 15 +- .../competitor_analysis/peewee.md | 15 +- .../competitor_analysis/prisma.md | 10 +- .../competitor_analysis/sqlalchemy.md | 5 +- .../competitor_analysis/sqlmodel.md | 10 +- .../competitor_analysis/tortoise_orm.md | 10 +- docs/zh_CN/logging/README.md | 10 +- docs/zh_CN/logging/data_summarization.md | 5 +- docs/zh_CN/logging/per_logger_config.md | 10 +- docs/zh_CN/modeling/batch_processing.md | 5 +- docs/zh_CN/modeling/best_practices.md | 24 +- docs/zh_CN/modeling/bulk_operations.md | 10 +- docs/zh_CN/modeling/ddl_source.md | 23 +- docs/zh_CN/modeling/ddl_source_examples.md | 12 +- docs/zh_CN/modeling/derived_fields.md | 10 +- docs/zh_CN/modeling/fields.md | 9 +- docs/zh_CN/modeling/keyless_models.md | 24 +- docs/zh_CN/modeling/mixins.md | 31 +- docs/zh_CN/modeling/readonly_models.md | 20 +- docs/zh_CN/querying/cheatsheet.md | 5 +- docs/zh_CN/relationships/definitions.md | 8 +- docs/zh_CN/relationships/eager_loading.md | 10 +- docs/zh_CN/testing/dummy.md | 5 +- scripts/d5_migrate_declarations.py | 141 +++++++ scripts/d5_migrate_docs.py | 82 ++++ scripts/d5_migrate_embedded.py | 63 +++ scripts/d5_normalize_blank_lines.py | 42 ++ scripts/p3_ci_refs.py | 53 +++ scripts/p3_fix_reads_and_writes.py | 95 +++++ src/rhosocial/activerecord/backend/errors.py | 7 +- src/rhosocial/activerecord/base/__init__.py | 2 + src/rhosocial/activerecord/base/base.py | 184 ++++++++- src/rhosocial/activerecord/base/ddl.py | 311 ++++++++++++++- src/rhosocial/activerecord/base/fields.py | 220 ++++++++++- src/rhosocial/activerecord/cli/backends.py | 4 +- src/rhosocial/activerecord/cli/errors.py | 2 +- src/rhosocial/activerecord/cli/interaction.py | 5 +- src/rhosocial/activerecord/cli/runtime.py | 35 +- .../activerecord/field/composite_pk.py | 12 +- .../activerecord/field/field_config.py | 9 +- src/rhosocial/activerecord/field/read_only.py | 56 +-- .../activerecord/field/soft_delete.py | 34 +- src/rhosocial/activerecord/field/timestamp.py | 32 +- src/rhosocial/activerecord/field/uuid.py | 47 ++- src/rhosocial/activerecord/field/version.py | 53 ++- src/rhosocial/activerecord/interface/model.py | 173 +++++--- src/rhosocial/activerecord/logging/mixin.py | 15 +- .../activerecord/query/active_query.py | 39 +- tests/providers/basic_connection.py | 14 +- tests/providers/query_connection.py | 10 +- .../named_connection/test_callable_config.py | 8 +- .../connection/test_declaration_accessors.py | 173 ++++++++ .../basic/crud/test_reload_stale_recipe.py | 175 +++++++++ .../ddl/test_ddl_composite_annotations.py | 362 +++++++++++++++++ .../ddl/test_ddl_declaration_interfaces.py | 89 ++++- .../basic/ddl/test_ddl_partition_key.py | 370 ++++++++++++++++++ .../ddl/test_soft_delete_partial_unique.py | 119 ++++++ .../basic/fields/test_derived_field.py | 10 +- .../basic/fields/test_derived_field_async.py | 10 +- .../test_key_generation_and_natural_key.py | 244 ++++++++++++ .../test_find_one_or_create.py | 5 +- .../test_find_one_or_create_async.py | 5 +- .../feature/basic/fixtures/models.py | 220 ++++++++--- .../feature/basic/fixtures/models_py310.py | 188 +++++++-- .../feature/basic/fixtures/models_py311.py | 188 +++++++-- .../feature/basic/fixtures/models_py312.py | 188 +++++++-- .../basic/nopk/test_keyless_models_sqlite.py | 30 +- .../nopk/test_keyless_models_sqlite_async.py | 25 +- .../basic/readonly/test_read_only_models.py | 44 ++- .../readonly/test_read_only_models_async.py | 29 +- .../activerecord_test/feature/cli/conftest.py | 48 ++- .../feature/cli/test_cli_commands.py | 20 +- .../feature/connection/conftest.py | 32 +- .../test_active_record_context_awareness.py | 8 +- .../test_backend_group_context_lifecycle.py | 16 +- .../test_backend_group_manual_lifecycle.py | 16 +- .../feature/events/fixtures/models.py | 12 +- .../feature/events/fixtures/models_py310.py | 8 +- .../feature/events/fixtures/models_py311.py | 8 +- .../feature/events/fixtures/models_py312.py | 8 +- .../interface/test_composite_pk_identity.py | 31 +- .../test_partial_key_scope_warning.py | 25 +- .../feature/mixins/fixtures/models.py | 32 +- .../feature/mixins/fixtures/models_py310.py | 16 +- .../feature/mixins/fixtures/models_py311.py | 16 +- .../feature/mixins/fixtures/models_py312.py | 16 +- .../mixins/test_field_config_validation.py | 95 +++-- .../feature/mixins/test_utc_timestamp.py | 8 +- .../feature/query/test_column_type_support.py | 20 +- .../feature/query/test_typed_columns.py | 20 +- .../feature/relation/fixtures/models.py | 72 +++- .../feature/relation/fixtures/models_py310.py | 24 +- .../feature/relation/fixtures/models_py311.py | 24 +- .../feature/relation/fixtures/models_py312.py | 24 +- .../ordering/test_has_many_order_by_unit.py | 54 ++- .../feature/relation/test_type_resolver.py | 45 ++- .../logging_module/test_crud_summarization.py | 4 +- .../logging_module/test_execution_logging.py | 9 +- .../test_model_logging_config.py | 80 +++- 148 files changed, 5402 insertions(+), 822 deletions(-) create mode 100644 .claude/rules/declaration-surfaces.md create mode 100644 scripts/d5_migrate_declarations.py create mode 100644 scripts/d5_migrate_docs.py create mode 100644 scripts/d5_migrate_embedded.py create mode 100644 scripts/d5_normalize_blank_lines.py create mode 100644 scripts/p3_ci_refs.py create mode 100644 scripts/p3_fix_reads_and_writes.py create mode 100644 tests/rhosocial/activerecord_test/feature/basic/connection/test_declaration_accessors.py create mode 100644 tests/rhosocial/activerecord_test/feature/basic/crud/test_reload_stale_recipe.py create mode 100644 tests/rhosocial/activerecord_test/feature/basic/ddl/test_ddl_composite_annotations.py create mode 100644 tests/rhosocial/activerecord_test/feature/basic/ddl/test_ddl_partition_key.py create mode 100644 tests/rhosocial/activerecord_test/feature/basic/ddl/test_soft_delete_partial_unique.py create mode 100644 tests/rhosocial/activerecord_test/feature/basic/fields/test_key_generation_and_natural_key.py diff --git a/.claude/architecture.md b/.claude/architecture.md index 1d03b594d..be5a515ec 100644 --- a/.claude/architecture.md +++ b/.claude/architecture.md @@ -597,7 +597,9 @@ class SQLiteBackend(StorageBackend): ```python class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" name: str email: str @@ -660,7 +662,10 @@ class SoftDeleteMixin: # hooks BEFORE_DELETE -> _mark_as_deleted # Composition class Article(TimestampMixin, SoftDeleteMixin, ActiveRecord): - __table_name__ = "articles" + @classmethod + def table_name(cls): + return "articles" + title: str content: str @@ -837,7 +842,10 @@ from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + name: str # 2. Configure backend diff --git a/.claude/code_style.md b/.claude/code_style.md index 439e13358..af5f0404e 100644 --- a/.claude/code_style.md +++ b/.claude/code_style.md @@ -157,8 +157,14 @@ User.query().where("name LIKE ?", ('%john%',)) # name LIKE '%john%' # Class attributes class User(ActiveRecord): - __table_name__ = "users" # Magic attribute - __primary_key__ = "id" # Magic attribute + @classmethod + def table_name(cls): + return "users" # Magic attribute + + @classmethod + def primary_key(cls): + return "id" # Magic attribute + _relations = {} # Protected class attribute # Instance attributes diff --git a/.claude/commands/gen-model.md b/.claude/commands/gen-model.md index 25a8b6929..6232be1b3 100644 --- a/.claude/commands/gen-model.md +++ b/.claude/commands/gen-model.md @@ -27,8 +27,13 @@ from rhosocial.activerecord.base import FieldProxy from rhosocial.activerecord.field import TimestampMixin # if selected class User(ActiveRecord): # or AsyncActiveRecord - __table_name__ = 'users' - __primary_key__ = 'id' # if not default + @classmethod + def table_name(cls): + return 'users' + + @classmethod + def primary_key(cls): + return 'id' # if not default # Fields name: str = Field(..., max_length=100) diff --git a/.claude/commands/gen-relation.md b/.claude/commands/gen-relation.md index a86df9438..b00ccbf15 100644 --- a/.claude/commands/gen-relation.md +++ b/.claude/commands/gen-relation.md @@ -31,7 +31,9 @@ from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.relation import BelongsTo class Order(ActiveRecord): - __table_name__ = 'orders' + @classmethod + def table_name(cls): + return 'orders' user_id: int total: float @@ -53,7 +55,9 @@ from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.relation import HasMany class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' name: str email: str @@ -75,7 +79,9 @@ from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.relation import HasOne class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' name: str @@ -103,7 +109,10 @@ Many-to-many relationships are implemented using two `has_many` relationships vi ```python # User model class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' + id: int name: str @@ -119,7 +128,10 @@ class User(ActiveRecord): # UserRole join table model class UserRole(ActiveRecord): - __table_name__ = 'user_roles' + @classmethod + def table_name(cls): + return 'user_roles' + user_id: int role_id: int @@ -128,7 +140,10 @@ class UserRole(ActiveRecord): # Role model class Role(ActiveRecord): - __table_name__ = 'roles' + @classmethod + def table_name(cls): + return 'roles' + id: int name: str diff --git a/.claude/commands/validate-model.md b/.claude/commands/validate-model.md index e7266c89c..2d432d770 100644 --- a/.claude/commands/validate-model.md +++ b/.claude/commands/validate-model.md @@ -43,7 +43,10 @@ Check the following aspects of the model: ```python # WRONG class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' + name: str def find_by_name(self, name): @@ -51,7 +54,10 @@ class User(ActiveRecord): # CORRECT class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' + name: str c: ClassVar[FieldProxy] = FieldProxy() diff --git a/.claude/field-proxy-comprehensive-guide.md b/.claude/field-proxy-comprehensive-guide.md index 761316466..92c2dd8fe 100644 --- a/.claude/field-proxy-comprehensive-guide.md +++ b/.claude/field-proxy-comprehensive-guide.md @@ -46,7 +46,10 @@ class FieldProxy: ```python class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + __primary_key__: ClassVar[str] = "id" # explicitly specify the primary key column name # Plain fields - field name and column name are the same diff --git a/.claude/rules/README.md b/.claude/rules/README.md index f4d9b6fbb..cb19df2fe 100644 --- a/.claude/rules/README.md +++ b/.claude/rules/README.md @@ -11,6 +11,7 @@ stands alone; read the one that concerns the change in front of you. | [operation-contracts.md](operation-contracts.md) | What each operation promises once the ten-backend survey measured it: the string boundary refusals, the rendering-name contracts, the temporal result types, the value-layer echo, and where version gates refuse. | | [static-analysis.md](static-analysis.md) | Making a checker actually check: `py.typed`, a configuration that loads, and the defects that opt out of checking silently. | | [anti-patterns.md](anti-patterns.md) | The catalogue, with what each one cost. Read this before adding a runtime lookup, a coercion helper, or a `__getattr__`. | +| [declaration-surfaces.md](declaration-surfaces.md) | The three surfaces a model may declare on (table-classmethod, field annotation, framework-managed runtime state), the accessors that replaced the declaration dunders, the special faces (partition key, natural key, locators), R1–R5, and the migration state of the legacy direct-write faces. | | [constants.md](constants.md) | What a name in capitals has to earn: read once means inline, read across functions means a test pins it, and one name means one shape across every repository. | | [python-hazards.md](python-hazards.md) | Python spelling and measured surface for the organisation's language-neutral clauses on override order, reflection and enums (expression-dialect.md §11). | | [introspection.md](introspection.md) | The four introspection rules: nobody spells SQL; core keeps the smallest set and does not arbitrate capability; each backend completes its own semantics; each CLI introspects one connection. | diff --git a/.claude/rules/constants.md b/.claude/rules/constants.md index e955e303b..549baac84 100644 --- a/.claude/rules/constants.md +++ b/.claude/rules/constants.md @@ -1,5 +1,10 @@ # Constants: what a name in caps has to earn +> Model declarations are not constants. A model declares its table name, key +> and locators by overriding accessor methods — see +> [declaration-surfaces.md](declaration-surfaces.md). A dunder assigned in a +> class body is not a declaration and is not read by the framework. + A module-level name written in capitals is a claim: that the value is a fact of the domain rather than a convenience of one call site, and that everyone who reads it sees the same one. The claim is either true or it is decoration, diff --git a/.claude/rules/declaration-surfaces.md b/.claude/rules/declaration-surfaces.md new file mode 100644 index 000000000..b43ba1687 --- /dev/null +++ b/.claude/rules/declaration-surfaces.md @@ -0,0 +1,187 @@ +# 声明面(Declaration Surfaces) + +本文档定义模型可以声明什么、在哪里声明、以及框架如何消费。它是**唯一**的声明口径: +其他文档、示例、docstring 与本文件冲突时,以本文件为准。 + +## 1. 三条面 + +一个模型只有三种声明渠道,每种只承载一类事实: + +| 面 | 形式 | 承载的事实 | 例子 | +|---|---|---|---| +| **表级面** | 覆盖 `DDLSource` 类方法 | 表名、schema、主键、分区键、索引、约束、表选项 | `table_name()`、`primary_key()`、`table_indexes()` | +| **字段级面** | 字段注记(`Annotated[T, Marker]`) | 列名、列类型、约束、索引、注释、分区键成员 | `UseColumn("id")`、`UseIndex("idx")`、`UsePartitionKey()` | +| **运行时面** | 框架管理的私有状态 | 连接、日志、追踪、缓存等运行时事实 | `_dummy_backend`、`_dirty_fields`、`_version_snapshot` | + +**dunder 不再是声明面。** `__table_name__`、`__primary_key__`、`__read_only__` 等 +一律不再声明任何东西;写它们不会生效,读它们属于框架内部实现细节。 + +## 2. 如何声明(速查) + +### 2.1 表级事实:覆盖类方法 + +```python +class User(ActiveRecord): + @classmethod + def table_name(cls): + return "users" + + @classmethod + def primary_key(cls): + return "id" # 复合键返回 tuple,None 表示无键 + + @classmethod + def schema_name(cls): + return "crm" # 不覆盖 = None,走连接默认 schema + + @classmethod + def pk_auto_generated(cls): + return False # 应用自备主键 +``` + +默认值一览(不覆盖时的行为): + +| 方法 | 默认 | 说明 | +|---|---|---| +| `table_name()` | **无默认** | 未覆盖 → `NotImplementedError`,在第一次读取处响亮失败 | +| `schema_name()` | `None` | 由连接默认 schema / search_path 解析 | +| `primary_key()` | `"id"` | 单列;返回 tuple 即复合键;返回 `None` 即声明无键模型 | +| `pk_auto_generated()` | `True` | 单列代理键由数据库生成 | +| `partition_key_columns()` | `()` | 见 §3.2 | +| `natural_key()` | `()` | 见 §3.3 | +| `table_indexes()` / `table_constraints()` | `[]` | 由注记衍生的部分自动并入 | +| `object_type()` | `TABLE` | `VIEW` / `MATERIALIZED_VIEW` | +| `deleted_at_field()` 等定位符 | 约定名 | 见 §3.4 | + +### 2.2 字段事实:注记 + +```python +from typing import Annotated + +class User(ActiveRecord): + user_id: Annotated[int, UseColumn("id")] + email: Annotated[str, UseIndex("idx_email", unique=True)] + status: Annotated[str, UseConstraint(ColumnCheckConstraint, condition=...)] +``` + +可用标记:`UseColumn`、`UseColumnType`、`UseSqlType`、`UseConstraint`、`UseIndex`、 +`UseColumnAttributes`、`UseComment`、`UseGeneratedColumn`、`UsePartitionKey`、 +`UseAdapter`、`DerivedField`。 + +多列索引/约束在**锚点字段**上声明,`columns` 是权威列表(`columns[0]` 必须是锚点列): + +```python +email: Annotated[str, UseIndex("idx_email_country", columns=["email", "country"])] +``` + +### 2.3 混入特性:覆盖定位符方法 + +```python +class Doc(DefaultSoftDeleteMixin, ActiveRecord): + @classmethod + def deleted_at_field(cls): + return "removed_on" # 默认 "deleted_at" + +class Post(DefaultTimestampMixin, ActiveRecord): + @classmethod + def created_at_field(cls): + return "published_at" +``` + +## 3. 专门面 + +### 3.1 分区键 + +两种声明面,**只能选一种**: + +```python +# 注记面:成员按 model_fields 顺序提取 +bucket: Annotated[str, UsePartitionKey()] +created_at: Annotated[str, UsePartitionKey()] + +# 直写面:内省生成的模型走这条 +__partition_key__ = ("bucket", "created_day") +``` + +- 两者同时声明 → 类创建期 `TypeError`(一个事实一个来源); +- 直写面只接受**纯列名元组**,表达式键暂不支持; +- 分区键不在主键内 → `UserWarning`(ClickHouse/BigQuery 合法,只是提醒)。 + +### 3.2 分区范围提示 + +- 查询侧:模型有分区键而 WHERE 未引用任何分区列 → 日志 WARNING; +- CLI:`--where` 未含分区列 → stderr 一行提示(`--quiet` 抑制)。 + +这是**提示**不是错误:读全部月份是正当需求。 + +### 3.3 自然键 + +```python +@classmethod +def natural_key(cls): + return ("tenant", "external_id") +``` + +声明后 `find_one_or_create(defaults={...})` 可以不带关键字用声明键取值; +显式关键字 lookup 仍然优先;未声明时行为与从前一致。 + +### 3.4 定位符 + +| 方法 | 默认 | 所属 | +|---|---|---| +| `deleted_at_field()` | `deleted_at` | 软删 | +| `created_at_field()` | `created_at` | 时间戳 | +| `updated_at_field()` | `updated_at` | 时间戳 | +| `version_field()` | `version` | 乐观锁 | +| `version_increment_by()` | `1` | 乐观锁 | +| `read_only()` | `True`(继承 ReadOnlyMixin 时) | 只读 | +| `logger_name()` | `None` | 日志 | +| `no_track_fields()` | MRO 并集 | 变更追踪 | +| `uuid_version()` | `"uuid4"` | UUID 主键 | + +## 4. 活着的声明 dunder + +以下 dunder **仍可作为声明**,其余一律不是: + +| dunder | 原因 | +|---|---| +| `__partition_key__` | 内省生成模型与表达式键的入口(§3.1) | +| `__table_indexes__` / `__table_constraints__` | 未注记化的遗留直写面,逐步迁移中(见 §6) | +| `__annotations__` / `ClassVar` 字段 | Pydantic 与 Python 本身 | + +## 5. 规则(R1–R5) + +- **R1** 消费方只经类方法访问事实;框架内部**不得**直读声明 dunder。 +- **R2** 用户覆盖永远赢:类体先于基类,方法覆盖优先级最高。 +- **R3** 一个事实一个来源:两处同时声明即类创建期 `TypeError` + (分区键 both-declared、索引同名冲突、复合声明锚点错位均属此类)。 +- **R4** 逐字段的答案优先于类级标志:`IdentityAttribute` 胜过 + `pk_auto_generated()`,`UseColumn` 胜过字段名。 +- **R5** 错误必须响亮:未声明 `table_name()` 报错而非返回 `None`; + 未消费的注记在访问器入口 `TypeError`,而非静默忽略。 + +## 6. 遗留与迁移 + +- `__table_indexes__` / `__table_constraints__` 直写面仍可读;同名单列/复合索引应改用 + `UseIndex` / `UseConstraint`,冲突检测已就位。 +- 后端包与应用包在各自版本迁移;在迁移完成前,它们在新版 core 上会因 + `NotImplementedError` / `TypeError` 直接失败——这是有意的,避免静默漂移。 +- 迁移工具:`scripts/d5_migrate_declarations.py`(AST 定位,改写类体 dunder 为方法覆盖) + 与 `scripts/d5_migrate_embedded.py`(改写字符串内嵌的模型源码)。 + +## 7. 反例(不要这样写) + +```python +class Bad(ActiveRecord): + @classmethod + def table_name(cls): + return "bad" # 不生效:改用 table_name() + @classmethod + def primary_key(cls): + return ("a", "b") # 不生效:改用 primary_key() + @classmethod + def read_only(cls): + return True # 不生效:改用 read_only() + __partition_key__ = ("a",) # 有效,但不能再同时用 UsePartitionKey + a: Annotated[int, UsePartitionKey()] # → TypeError:一个事实两个来源 +``` diff --git a/.claude/skills/dev-testing-contributor/SKILL.md b/.claude/skills/dev-testing-contributor/SKILL.md index b83c31bc8..37227a532 100644 --- a/.claude/skills/dev-testing-contributor/SKILL.md +++ b/.claude/skills/dev-testing-contributor/SKILL.md @@ -220,7 +220,10 @@ class SQLiteProvider: """Setup basic CRUD models.""" class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' + __backend__ = backend id: int = Field(primary_key=True) @@ -234,7 +237,10 @@ class SQLiteProvider: } class Profile(ActiveRecord): - __table_name__ = 'profiles' + @classmethod + def table_name(cls): + return 'profiles' + __backend__ = backend id: int = Field(primary_key=True) @@ -256,7 +262,10 @@ class SQLiteProvider: """Setup query testing models.""" class Order(ActiveRecord): - __table_name__ = 'orders' + @classmethod + def table_name(cls): + return 'orders' + __backend__ = backend id: int = Field(primary_key=True) @@ -274,7 +283,10 @@ class SQLiteProvider: } class OrderItem(ActiveRecord): - __table_name__ = 'order_items' + @classmethod + def table_name(cls): + return 'order_items' + __backend__ = backend id: int = Field(primary_key=True) @@ -300,7 +312,10 @@ class SQLiteProvider: """Setup relation testing models.""" class Author(ActiveRecord): - __table_name__ = 'authors' + @classmethod + def table_name(cls): + return 'authors' + __backend__ = backend id: int = Field(primary_key=True) @@ -314,7 +329,10 @@ class SQLiteProvider: } class Book(ActiveRecord): - __table_name__ = 'books' + @classmethod + def table_name(cls): + return 'books' + __backend__ = backend id: int = Field(primary_key=True) @@ -330,7 +348,10 @@ class SQLiteProvider: } class BookCategory(ActiveRecord): - __table_name__ = 'book_categories' + @classmethod + def table_name(cls): + return 'book_categories' + __backend__ = backend id: int = Field(primary_key=True) diff --git a/.claude/skills/user-activerecord-pattern/SKILL.md b/.claude/skills/user-activerecord-pattern/SKILL.md index 80380ea16..a668401fd 100644 --- a/.claude/skills/user-activerecord-pattern/SKILL.md +++ b/.claude/skills/user-activerecord-pattern/SKILL.md @@ -45,7 +45,9 @@ from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend from rhosocial.activerecord.base import FieldProxy class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' name: str = Field(..., max_length=100) email: str @@ -72,7 +74,10 @@ from typing import ClassVar from rhosocial.activerecord.base import FieldProxy class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' + name: str email: str age: int = 0 @@ -113,7 +118,10 @@ from typing import ClassVar from rhosocial.activerecord.base import FieldProxy class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' + name: str age: int status: str @@ -209,7 +217,10 @@ from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.relation import HasMany class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' + name: str # Define relationship with string reference @@ -219,7 +230,10 @@ class User(ActiveRecord): ) class Post(ActiveRecord): - __table_name__ = 'posts' + @classmethod + def table_name(cls): + return 'posts' + title: str user_id: int diff --git a/.claude/skills/user-enterprise-features/SKILL.md b/.claude/skills/user-enterprise-features/SKILL.md index 980349289..007b9632d 100644 --- a/.claude/skills/user-enterprise-features/SKILL.md +++ b/.claude/skills/user-enterprise-features/SKILL.md @@ -45,7 +45,9 @@ from rhosocial.activerecord.field import ( from rhosocial.activerecord.base import FieldProxy class Inventory(VersionMixin, ActiveRecord): - __table_name__ = 'inventory' + @classmethod + def table_name(cls): + return 'inventory' product_name: str quantity: int @@ -114,7 +116,9 @@ from rhosocial.activerecord.field import SoftDeleteMixin from rhosocial.activerecord.base import FieldProxy class Comment(SoftDeleteMixin, ActiveRecord): - __table_name__ = 'comments' + @classmethod + def table_name(cls): + return 'comments' content: str author: str @@ -191,7 +195,9 @@ from rhosocial.activerecord.field import TimestampMixin from rhosocial.activerecord.base import FieldProxy class Article(TimestampMixin, ActiveRecord): - __table_name__ = 'articles' + @classmethod + def table_name(cls): + return 'articles' title: str content: str @@ -239,7 +245,9 @@ from rhosocial.activerecord.field import TimestampMixin from rhosocial.activerecord.base import FieldProxy class File(UUIDMixin, ActiveRecord): - __table_name__ = 'files' + @classmethod + def table_name(cls): + return 'files' filename: str size: int @@ -271,7 +279,9 @@ from rhosocial.activerecord.field import UUIDMixin import uuid class CustomFile(UUIDMixin, ActiveRecord): - __table_name__ = 'files' + @classmethod + def table_name(cls): + return 'files' @classmethod def _generate_uuid(cls): @@ -305,7 +315,9 @@ from rhosocial.activerecord.field import TimestampMixin from rhosocial.activerecord.base import FieldProxy class Order(TimestampMixin, ActiveRecord): - __table_name__ = 'orders' + @classmethod + def table_name(cls): + return 'orders' amount: float status: str = 'pending' @@ -326,7 +338,9 @@ Order.configure(backend) ```python class AuditLog(ActiveRecord): - __table_name__ = 'audit_logs' + @classmethod + def table_name(cls): + return 'audit_logs' action: str details: str @@ -334,7 +348,9 @@ class AuditLog(ActiveRecord): c: ClassVar = FieldProxy() class User(TimestampMixin, ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' name: str email: str @@ -370,7 +386,9 @@ class User(TimestampMixin, ActiveRecord): import asyncio class Notification(TimestampMixin, ActiveRecord): - __table_name__ = 'notifications' + @classmethod + def table_name(cls): + return 'notifications' user_id: int message: str @@ -404,7 +422,9 @@ class Product( VersionMixin, # version for concurrency ActiveRecord ): - __table_name__ = 'products' + @classmethod + def table_name(cls): + return 'products' name: str price: float diff --git a/.claude/skills/user-getting-started/SKILL.md b/.claude/skills/user-getting-started/SKILL.md index 8f7e7c4ac..eabe9fff2 100644 --- a/.claude/skills/user-getting-started/SKILL.md +++ b/.claude/skills/user-getting-started/SKILL.md @@ -50,7 +50,9 @@ from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend from rhosocial.activerecord.base import FieldProxy class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' name: str email: str diff --git a/.claude/skills/user-modeling-guide/SKILL.md b/.claude/skills/user-modeling-guide/SKILL.md index 96c34bcaa..900405e1f 100644 --- a/.claude/skills/user-modeling-guide/SKILL.md +++ b/.claude/skills/user-modeling-guide/SKILL.md @@ -51,7 +51,9 @@ class User(UUIDMixin, TimestampMixin, SoftDeleteMixin, VersionMixin, ActiveRecor - SoftDeleteMixin: deleted_at for soft deletion - VersionMixin: version for optimistic locking """ - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' # Basic fields with validation username: str = Field(..., min_length=3, max_length=50) @@ -83,7 +85,9 @@ from datetime import datetime, date, time from decimal import Decimal class Product(ActiveRecord): - __table_name__ = 'products' + @classmethod + def table_name(cls): + return 'products' # Strings name: str @@ -112,7 +116,9 @@ class Product(ActiveRecord): from pydantic import Field, validator, root_validator class Order(ActiveRecord): - __table_name__ = 'orders' + @classmethod + def table_name(cls): + return 'orders' # Field constraints quantity: int = Field(..., ge=1, le=1000) @@ -145,7 +151,10 @@ from typing import ClassVar from rhosocial.activerecord.base import FieldProxy class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' + name: str age: int @@ -166,7 +175,10 @@ from rhosocial.activerecord.field import TimestampMixin class Post(TimestampMixin, ActiveRecord): """Adds created_at and updated_at automatically.""" - __table_name__ = 'posts' + @classmethod + def table_name(cls): + return 'posts' + title: str c: ClassVar[FieldProxy] = FieldProxy() @@ -186,7 +198,10 @@ from rhosocial.activerecord.field import UUIDMixin class File(UUIDMixin, ActiveRecord): """Uses UUID as primary key instead of auto-increment int.""" - __table_name__ = 'files' + @classmethod + def table_name(cls): + return 'files' + filename: str c: ClassVar[FieldProxy] = FieldProxy() @@ -205,7 +220,10 @@ from rhosocial.activerecord.field import SoftDeleteMixin class Comment(SoftDeleteMixin, ActiveRecord): """Soft deletion - records marked but not removed.""" - __table_name__ = 'comments' + @classmethod + def table_name(cls): + return 'comments' + content: str c: ClassVar[FieldProxy] = FieldProxy() @@ -227,7 +245,10 @@ from rhosocial.activerecord.field import VersionMixin class Inventory(VersionMixin, ActiveRecord): """Prevents concurrent update conflicts.""" - __table_name__ = 'inventory' + @classmethod + def table_name(cls): + return 'inventory' + product_name: str quantity: int @@ -246,14 +267,20 @@ from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.relation import HasMany, BelongsTo class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' + name: str c: ClassVar[FieldProxy] = FieldProxy() posts: ClassVar[HasMany['Post']] = HasMany(foreign_key='user_id', inverse_of='author') class Post(ActiveRecord): - __table_name__ = 'posts' + @classmethod + def table_name(cls): + return 'posts' + title: str user_id: int @@ -267,7 +294,10 @@ class Post(ActiveRecord): ```python class UserProfile(ActiveRecord): - __table_name__ = 'user_profiles' # Snake case, plural + @classmethod + def table_name(cls): + return 'user_profiles' # Snake case, plural + ``` ### Default Values @@ -276,7 +306,9 @@ class UserProfile(ActiveRecord): from datetime import datetime class Article(ActiveRecord): - __table_name__ = 'articles' + @classmethod + def table_name(cls): + return 'articles' title: str status: str = "draft" # Default value @@ -288,7 +320,9 @@ class Article(ActiveRecord): ```python class Product(ActiveRecord): - __table_name__ = 'products' + @classmethod + def table_name(cls): + return 'products' # Required (no default) name: str diff --git a/.claude/skills/user-performance-tuning/SKILL.md b/.claude/skills/user-performance-tuning/SKILL.md index 990d0c887..38ed1c5cb 100644 --- a/.claude/skills/user-performance-tuning/SKILL.md +++ b/.claude/skills/user-performance-tuning/SKILL.md @@ -83,7 +83,9 @@ Ensure your database has appropriate indexes for frequently queried columns. ```python class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' id: int = Field(primary_key=True) email: str = Field(max_length=255) @@ -285,7 +287,9 @@ Cache frequently accessed model instances by their primary key. ```python class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' id: int = Field(primary_key=True) name: str = Field() @@ -324,7 +328,9 @@ Implement proper cache invalidation strategies. ```python class Product(ActiveRecord): - __table_name__ = 'products' + @classmethod + def table_name(cls): + return 'products' id: int = Field(primary_key=True) name: str = Field() @@ -429,7 +435,9 @@ Use prepared statements for frequently executed queries. ```python class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' id: int = Field(primary_key=True) email: str = Field(max_length=255) diff --git a/.claude/skills/user-relationships/SKILL.md b/.claude/skills/user-relationships/SKILL.md index c9ffdc326..4713c40f3 100644 --- a/.claude/skills/user-relationships/SKILL.md +++ b/.claude/skills/user-relationships/SKILL.md @@ -37,7 +37,9 @@ from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.relation import BelongsTo class Comment(ActiveRecord): - __table_name__ = 'comments' + @classmethod + def table_name(cls): + return 'comments' content: str user_id: int # Foreign key @@ -62,7 +64,9 @@ class Comment(ActiveRecord): ```python class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' name: str c: ClassVar = FieldProxy() @@ -74,7 +78,9 @@ class User(ActiveRecord): ) class Profile(ActiveRecord): - __table_name__ = 'profiles' + @classmethod + def table_name(cls): + return 'profiles' bio: str user_id: int # Foreign key @@ -91,7 +97,9 @@ class Profile(ActiveRecord): ```python class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' name: str c: ClassVar = FieldProxy() @@ -109,7 +117,9 @@ class User(ActiveRecord): ) class Post(ActiveRecord): - __table_name__ = 'posts' + @classmethod + def table_name(cls): + return 'posts' title: str user_id: int @@ -126,7 +136,9 @@ class Post(ActiveRecord): ```python class Student(ActiveRecord): - __table_name__ = 'students' + @classmethod + def table_name(cls): + return 'students' name: str c: ClassVar = FieldProxy() @@ -139,7 +151,9 @@ class Student(ActiveRecord): ) class Course(ActiveRecord): - __table_name__ = 'courses' + @classmethod + def table_name(cls): + return 'courses' name: str c: ClassVar = FieldProxy() @@ -152,7 +166,9 @@ class Course(ActiveRecord): ) class Enrollment(ActiveRecord): - __table_name__ = 'enrollments' + @classmethod + def table_name(cls): + return 'enrollments' student_id: int course_id: int diff --git a/.claude/skills/user-troubleshooting/SKILL.md b/.claude/skills/user-troubleshooting/SKILL.md index 78915024b..b25b61383 100644 --- a/.claude/skills/user-troubleshooting/SKILL.md +++ b/.claude/skills/user-troubleshooting/SKILL.md @@ -79,7 +79,10 @@ def some_function(): ```python # ❌ Error - no backend configured class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' + ... user = User(name="John") @@ -98,7 +101,10 @@ User.configure(SQLiteBackend("sqlite:///app.db")) # Or set at class level class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' + __backend__ = SQLiteBackend("sqlite:///app.db") ... @@ -141,11 +147,17 @@ else: ```python # ❌ Problematic - different backends class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' + __backend__ = backend1 class Order(ActiveRecord): - __table_name__ = 'orders' + @classmethod + def table_name(cls): + return 'orders' + __backend__ = backend2 # User and Order can't be used together in queries @@ -154,11 +166,17 @@ class Order(ActiveRecord): shared_backend = SQLiteBackend("sqlite:///app.db") class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' + __backend__ = shared_backend class Order(ActiveRecord): - __table_name__ = 'orders' + @classmethod + def table_name(cls): + return 'orders' + __backend__ = shared_backend # Now User and Order can be joined @@ -173,7 +191,10 @@ class Order(ActiveRecord): ```python # ❌ Error - 'email' field doesn't exist class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' + id: int = Field(primary_key=True) name: str = Field() @@ -188,7 +209,10 @@ User.query().where(User.c.name == "John") # Or add the missing field class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' + id: int = Field(primary_key=True) name: str = Field() email: str = Field() # Add email field @@ -267,7 +291,10 @@ else: ```python # ❌ Error - string for integer field class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' + age: int = Field() User.query().where(User.c.age == "25") # String instead of int @@ -285,7 +312,10 @@ User.query().where(User.c.age == 25) # Integer ```python # ❌ Error - no 'orders' relation defined class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' + ... user = User.find_one() @@ -301,7 +331,10 @@ from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.relation import HasMany, BelongsTo class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' + id: int = Field(primary_key=True) name: str = Field() @@ -312,7 +345,10 @@ class User(ActiveRecord): ) class Order(ActiveRecord): - __table_name__ = 'orders' + @classmethod + def table_name(cls): + return 'orders' + id: int = Field(primary_key=True) user_id: int = Field() total: float = Field() @@ -341,7 +377,10 @@ from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.relation import BelongsTo, HasMany class Order(ActiveRecord): - __table_name__ = 'orders' + @classmethod + def table_name(cls): + return 'orders' + user_id: int = Field() # Wrong: BelongsTo should reference the parent, not self @@ -352,7 +391,10 @@ class Order(ActiveRecord): # ✅ Correct relation definition class Order(ActiveRecord): - __table_name__ = 'orders' + @classmethod + def table_name(cls): + return 'orders' + id: int = Field(primary_key=True) user_id: int = Field() @@ -392,7 +434,10 @@ User.query().includes({'orders': 'items'}).all() ```python # ❌ Error - validation failure class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' + name: str = Field(max_length=10) email: str = Field(max_length=255) @@ -409,7 +454,10 @@ user.save() # Works # Or adjust the constraint class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' + name: str = Field(max_length=100) # Increased limit ``` @@ -420,7 +468,10 @@ class User(ActiveRecord): ```python # ❌ Error - duplicate email class User(ActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' + email: str = Field(unique=True) User.create(name="John", email="test@example.com") @@ -483,7 +534,10 @@ user = await User.query() # query() is sync from rhosocial.activerecord.model import AsyncActiveRecord class AsyncUser(AsyncActiveRecord): - __table_name__ = 'users' + @classmethod + def table_name(cls): + return 'users' + id: int = Field(primary_key=True) name: str = Field() email: str = Field() diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 098e39665..eaa843c23 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,7 +55,7 @@ jobs: pip install -r requirements-dev.txt pip install codecov build - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/declaration-surface" - name: Run tests with coverage env: @@ -112,7 +112,7 @@ jobs: pip install -r requirements.txt pip install -r requirements-dev.txt - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/declaration-surface" - name: Run benchmark suite env: @@ -279,7 +279,7 @@ jobs: pip install -r requirements-dev.txt fi - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/declaration-surface" - name: Run tests env: @@ -340,7 +340,7 @@ jobs: pip install -r requirements-dev.txt fi - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/declaration-surface" - name: Run tests env: @@ -397,7 +397,7 @@ jobs: run: | pip install -r requirements.txt pip install -r requirements-dev.txt - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/declaration-surface" - name: Run tests env: diff --git a/docs/LLM_CONTEXT.md b/docs/LLM_CONTEXT.md index 23ec1b4e4..9652d1ab6 100644 --- a/docs/LLM_CONTEXT.md +++ b/docs/LLM_CONTEXT.md @@ -138,7 +138,10 @@ from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.base import FieldProxy class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + name: str = Field(max_length=100) email: str age: int = 0 @@ -167,14 +170,20 @@ from typing import ClassVar from rhosocial.activerecord.relation import HasMany, BelongsTo class Author(ActiveRecord): - __table_name__ = "authors" + @classmethod + def table_name(cls): + return "authors" + name: str c: ClassVar[FieldProxy] = FieldProxy() # Use ClassVar to prevent Pydantic from tracking as model fields posts: ClassVar[HasMany["Post"]] = HasMany(foreign_key="author_id") class Post(ActiveRecord): - __table_name__ = "posts" + @classmethod + def table_name(cls): + return "posts" + title: str author_id: int c: ClassVar[FieldProxy] = FieldProxy() @@ -198,7 +207,10 @@ await user.save() from rhosocial.activerecord.field import DefaultTimestampMixin, DefaultSoftDeleteMixin class Article(ActiveRecord, DefaultTimestampMixin, DefaultSoftDeleteMixin): - __table_name__ = "articles" + @classmethod + def table_name(cls): + return "articles" + title: str body: str # Automatically has: created_at, updated_at, deleted_at diff --git a/docs/en_US/backend/template/backend_specific_features/dialect.md b/docs/en_US/backend/template/backend_specific_features/dialect.md index 73723f6eb..0c6fff548 100644 --- a/docs/en_US/backend/template/backend_specific_features/dialect.md +++ b/docs/en_US/backend/template/backend_specific_features/dialect.md @@ -311,7 +311,10 @@ from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionCo from rhosocial.activerecord.model import ActiveRecord class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + c: ClassVar[FieldProxy] = FieldProxy() id: int age: int diff --git a/docs/en_US/connection/connection_pool.md b/docs/en_US/connection/connection_pool.md index 2941d7d2f..f32119966 100644 --- a/docs/en_US/connection/connection_pool.md +++ b/docs/en_US/connection/connection_pool.md @@ -541,7 +541,10 @@ from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.field import IntegerPKMixin class User(IntegerPKMixin, ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: Optional[int] = None name: str email: str @@ -593,7 +596,10 @@ with pool.context(): from rhosocial.activerecord.model import AsyncActiveRecord class AsyncUser(IntegerPKMixin, AsyncActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: Optional[int] = None name: str email: str diff --git a/docs/en_US/getting_started/installation.md b/docs/en_US/getting_started/installation.md index 58ab0b7aa..3bf360124 100644 --- a/docs/en_US/getting_started/installation.md +++ b/docs/en_US/getting_started/installation.md @@ -143,7 +143,10 @@ from typing import ClassVar # Define simple model for testing class TestModel(ActiveRecord): - __table_name__ = "test_table" + @classmethod + def table_name(cls): + return "test_table" + name: str c: ClassVar[FieldProxy] = FieldProxy() diff --git a/docs/en_US/introduction/coming_from_frameworks.md b/docs/en_US/introduction/coming_from_frameworks.md index 653600f35..57bafe80b 100644 --- a/docs/en_US/introduction/coming_from_frameworks.md +++ b/docs/en_US/introduction/coming_from_frameworks.md @@ -116,7 +116,10 @@ class User(models.Model): **rhosocial-activerecord:** ```python class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + username: str = Field(max_length=50) email: str ``` diff --git a/docs/en_US/introduction/competitor_analysis/django_orm.md b/docs/en_US/introduction/competitor_analysis/django_orm.md index 98509faca..0a8c015e5 100644 --- a/docs/en_US/introduction/competitor_analysis/django_orm.md +++ b/docs/en_US/introduction/competitor_analysis/django_orm.md @@ -32,7 +32,10 @@ from myapp.models import User # Can only define in Django apps from rhosocial.activerecord.model import ActiveRecord class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: Optional[int] = None name: str @@ -251,14 +254,20 @@ author.post_set.all() # Auto-generated reverse name ```python # More flexible relationship definition class Author(ActiveRecord): - __table_name__ = "authors" + @classmethod + def table_name(cls): + return "authors" + id: Optional[int] = None name: str c: ClassVar[FieldProxy] = FieldProxy() posts: ClassVar[HasMany["Post"]] = HasMany(foreign_key="author_id") class Post(ActiveRecord): - __table_name__ = "posts" + @classmethod + def table_name(cls): + return "posts" + id: Optional[int] = None title: str author_id: int diff --git a/docs/en_US/introduction/competitor_analysis/peewee.md b/docs/en_US/introduction/competitor_analysis/peewee.md index db428a24e..76e76da11 100644 --- a/docs/en_US/introduction/competitor_analysis/peewee.md +++ b/docs/en_US/introduction/competitor_analysis/peewee.md @@ -34,7 +34,10 @@ from rhosocial.activerecord.model import ActiveRecord from typing import Optional class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: Optional[int] = None name: str = Field(max_length=100) age: int = 0 @@ -176,7 +179,10 @@ from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.relation import HasMany, BelongsTo class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: Optional[int] = None name: str @@ -184,7 +190,10 @@ class User(ActiveRecord): posts: ClassVar[HasMany["Post"]] = HasMany(foreign_key="author_id") class Post(ActiveRecord): - __table_name__ = "posts" + @classmethod + def table_name(cls): + return "posts" + id: Optional[int] = None title: str author_id: int diff --git a/docs/en_US/introduction/competitor_analysis/prisma.md b/docs/en_US/introduction/competitor_analysis/prisma.md index 0b727252f..afd0eb988 100644 --- a/docs/en_US/introduction/competitor_analysis/prisma.md +++ b/docs/en_US/introduction/competitor_analysis/prisma.md @@ -51,7 +51,10 @@ from rhosocial.activerecord.relation import HasMany, BelongsTo from typing import ClassVar, Optional class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: Optional[int] = None name: str @@ -59,7 +62,10 @@ class User(ActiveRecord): posts: ClassVar[HasMany["Post"]] = HasMany(foreign_key="author_id") class Post(ActiveRecord): - __table_name__ = "posts" + @classmethod + def table_name(cls): + return "posts" + id: Optional[int] = None title: str author_id: int diff --git a/docs/en_US/introduction/competitor_analysis/sqlalchemy.md b/docs/en_US/introduction/competitor_analysis/sqlalchemy.md index 94ca395c9..a63fedefe 100644 --- a/docs/en_US/introduction/competitor_analysis/sqlalchemy.md +++ b/docs/en_US/introduction/competitor_analysis/sqlalchemy.md @@ -124,7 +124,10 @@ class User(Base): ```python # Native Pydantic style class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: Optional[int] = None # Primary key name: str = Field(max_length=50) # Pydantic Field diff --git a/docs/en_US/introduction/competitor_analysis/sqlmodel.md b/docs/en_US/introduction/competitor_analysis/sqlmodel.md index b9dcbf6a9..8be2e1e9c 100644 --- a/docs/en_US/introduction/competitor_analysis/sqlmodel.md +++ b/docs/en_US/introduction/competitor_analysis/sqlmodel.md @@ -184,7 +184,10 @@ from rhosocial.activerecord.relation import HasMany, BelongsTo from typing import ClassVar, Optional class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: Optional[int] = None name: str = Field(max_length=100) @@ -192,7 +195,10 @@ class User(ActiveRecord): posts: ClassVar[HasMany["Post"]] = HasMany(foreign_key="author_id") class Post(ActiveRecord): - __table_name__ = "posts" + @classmethod + def table_name(cls): + return "posts" + id: Optional[int] = None title: str author_id: int diff --git a/docs/en_US/introduction/competitor_analysis/tortoise_orm.md b/docs/en_US/introduction/competitor_analysis/tortoise_orm.md index e04f784e5..04c6e757a 100644 --- a/docs/en_US/introduction/competitor_analysis/tortoise_orm.md +++ b/docs/en_US/introduction/competitor_analysis/tortoise_orm.md @@ -85,7 +85,10 @@ from rhosocial.activerecord.relation import HasMany, BelongsTo from typing import ClassVar, Optional class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: Optional[int] = None name: str = Field(max_length=100) age: int = 0 @@ -94,7 +97,10 @@ class User(ActiveRecord): posts: ClassVar[HasMany["Post"]] = HasMany(foreign_key="author_id") class Post(ActiveRecord): - __table_name__ = "posts" + @classmethod + def table_name(cls): + return "posts" + id: Optional[int] = None title: str = Field(max_length=200) author_id: int diff --git a/docs/en_US/logging/README.md b/docs/en_US/logging/README.md index b5305079d..2087e9a78 100644 --- a/docs/en_US/logging/README.md +++ b/docs/en_US/logging/README.md @@ -76,13 +76,19 @@ ActiveRecord.__logging_config__ = LoggingConfig( ) class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: Optional[int] = None username: str password: str class AuditUser(ActiveRecord): - __table_name__ = "audit_users" + @classmethod + def table_name(cls): + return "audit_users" + __logging_config__ = LoggingConfig(log_data_mode=LogDataMode.KEYS_ONLY) id: Optional[int] = None username: str diff --git a/docs/en_US/logging/data_summarization.md b/docs/en_US/logging/data_summarization.md index 6d30ae320..b5065252a 100644 --- a/docs/en_US/logging/data_summarization.md +++ b/docs/en_US/logging/data_summarization.md @@ -124,7 +124,10 @@ from rhosocial.activerecord.logging import LoggingConfig, LogDataMode from rhosocial.activerecord.model import ActiveRecord class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + __logging_config__ = LoggingConfig(log_data_mode=LogDataMode.SUMMARY) id: Optional[int] = None username: str diff --git a/docs/en_US/logging/per_logger_config.md b/docs/en_US/logging/per_logger_config.md index 0ea38bb15..d752fda9e 100644 --- a/docs/en_US/logging/per_logger_config.md +++ b/docs/en_US/logging/per_logger_config.md @@ -19,7 +19,10 @@ from rhosocial.activerecord.model import ActiveRecord class User(ActiveRecord): __logger_name__ = "myapp.models.user" - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + ``` Changing `__logger_name__` changes the logger target. Data visibility still comes from the model's `LoggingConfig`. @@ -46,7 +49,10 @@ A concrete model can own a completely separate config: ```python class AuditUser(ActiveRecord): - __table_name__ = "audit_users" + @classmethod + def table_name(cls): + return "audit_users" + __logging_config__ = LoggingConfig(log_data_mode=LogDataMode.HIDDEN) ``` diff --git a/docs/en_US/modeling/batch_processing.md b/docs/en_US/modeling/batch_processing.md index fcfc249eb..386ecaa9c 100644 --- a/docs/en_US/modeling/batch_processing.md +++ b/docs/en_US/modeling/batch_processing.md @@ -20,7 +20,10 @@ from rhosocial.activerecord.model import ActiveRecord class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None name: str diff --git a/docs/en_US/modeling/best_practices.md b/docs/en_US/modeling/best_practices.md index 737e3597c..88ea72e48 100644 --- a/docs/en_US/modeling/best_practices.md +++ b/docs/en_US/modeling/best_practices.md @@ -589,7 +589,10 @@ from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: Optional[int] = None name: str email: str @@ -652,11 +655,17 @@ class UserFieldsMixin: email: str class User(UserFieldsMixin, ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + c: ClassVar[FieldProxy] = FieldProxy() class UserMetric(UserFieldsMixin, ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + c: ClassVar[FieldProxy] = FieldProxy() ``` @@ -672,10 +681,15 @@ class UserFieldsMixin(BaseModel): email: str class User(UserFieldsMixin, ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" class UserMetric(UserFieldsMixin, ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + ``` Both styles produce identical runtime behavior. Configure each class independently: diff --git a/docs/en_US/modeling/ddl_source.md b/docs/en_US/modeling/ddl_source.md index bca834c96..d24d7cb13 100644 --- a/docs/en_US/modeling/ddl_source.md +++ b/docs/en_US/modeling/ddl_source.md @@ -7,7 +7,9 @@ from rhosocial.activerecord.base import DDLSource from rhosocial.activerecord.model import ActiveRecord class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" id: int name: str @@ -83,7 +85,10 @@ class BackendSettingHandler(DDLAnnotationHandler): metadata.add_column_options(annotation.option) class Report(ActiveRecord): - __table_name__ = "reports" + @classmethod + def table_name(cls): + return "reports" + _feature_handlers = [BackendSettingHandler] id: int @@ -231,9 +236,17 @@ from rhosocial.activerecord.model import ActiveRecord class Account(ActiveRecord): - __table_name__ = "accounts" - __schema_name__ = "app" - __primary_key__ = "account_id" + @classmethod + def table_name(cls): + return "accounts" + + @classmethod + def schema_name(cls): + return "app" + + @classmethod + def primary_key(cls): + return "account_id" account_id: Annotated[int, UseColumn("id")] display_name: Annotated[ diff --git a/docs/en_US/modeling/ddl_source_examples.md b/docs/en_US/modeling/ddl_source_examples.md index 8f6326219..02f0045ad 100644 --- a/docs/en_US/modeling/ddl_source_examples.md +++ b/docs/en_US/modeling/ddl_source_examples.md @@ -46,7 +46,9 @@ from rhosocial.activerecord.model import ActiveRecord class PlainAccount(ActiveRecord): - __table_name__ = "plain_accounts" + @classmethod + def table_name(cls): + return "plain_accounts" id: int name: str @@ -147,7 +149,9 @@ from rhosocial.activerecord.model import ActiveRecord class CoreDeclared(ActiveRecord): - __table_name__ = "core_declared" + @classmethod + def table_name(cls): + return "core_declared" id: Annotated[int, UseColumn("record_id")] name: Annotated[ @@ -500,7 +504,9 @@ This scenario intentionally contains no generic core candidate: ```python class ForeignOnly(ActiveRecord): - __table_name__ = "foreign_only" + @classmethod + def table_name(cls): + return "foreign_only" id: int value: Annotated[str, UseSqlType(ForeignType())] diff --git a/docs/en_US/modeling/derived_fields.md b/docs/en_US/modeling/derived_fields.md index 647842e70..07c22ef97 100644 --- a/docs/en_US/modeling/derived_fields.md +++ b/docs/en_US/modeling/derived_fields.md @@ -19,7 +19,10 @@ from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.base.fields import DerivedField class Product(ActiveRecord): - __table_name__ = "products" + @classmethod + def table_name(cls): + return "products" + id: int name: str price: Decimal @@ -40,7 +43,10 @@ from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.base.fields import DerivedField class Product(ActiveRecord): - __table_name__ = "products" + @classmethod + def table_name(cls): + return "products" + id: int name: str price: Decimal diff --git a/docs/en_US/modeling/fields.md b/docs/en_US/modeling/fields.md index af2e2e988..bf77ecefb 100644 --- a/docs/en_US/modeling/fields.md +++ b/docs/en_US/modeling/fields.md @@ -59,7 +59,9 @@ from rhosocial.activerecord.backend.expression.types import ( ) class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" # Explicitly VARCHAR(100), instead of the default TEXT username: Annotated[str, UseSqlType(VarCharType(length=100))] @@ -201,7 +203,10 @@ from typing import ClassVar, Optional from rhosocial.activerecord.base import FieldProxy class Employee(ActiveRecord): - __table_name__ = "employees" + @classmethod + def table_name(cls): + return "employees" + id: int name: str manager_id: Optional[int] # points to the same table's id diff --git a/docs/en_US/modeling/keyless_models.md b/docs/en_US/modeling/keyless_models.md index 089540bdb..566ef9516 100644 --- a/docs/en_US/modeling/keyless_models.md +++ b/docs/en_US/modeling/keyless_models.md @@ -18,8 +18,13 @@ from typing import Optional from rhosocial.activerecord.model import ActiveRecord class AuditLog(ActiveRecord): - __table_name__ = "audit_log" - __primary_key__ = None + @classmethod + def table_name(cls): + return "audit_log" + + @classmethod + def primary_key(cls): + return None event: str level: str @@ -114,15 +119,24 @@ The two dimensions do not interact: ```python class KeylessWritable(ActiveRecord): - __primary_key__ = None + @classmethod + def primary_key(cls): + return None + ... class ReadOnlyWithKey(ActiveRecord, ReadOnlyMixin): - __table_name__ = "records" + @classmethod + def table_name(cls): + return "records" + ... class KeylessReadOnly(ActiveRecord, ReadOnlyMixin): - __primary_key__ = None + @classmethod + def primary_key(cls): + return None + ... ``` diff --git a/docs/en_US/modeling/mixins.md b/docs/en_US/modeling/mixins.md index 05bb51f20..e27bf04d6 100644 --- a/docs/en_US/modeling/mixins.md +++ b/docs/en_US/modeling/mixins.md @@ -76,7 +76,9 @@ Supports composite (multi-column) primary keys. from rhosocial.activerecord.field import CompositePKMixin class OrderItem(CompositePKMixin, ActiveRecord): - __primary_key__ = ("order_id", "product_id") + @classmethod + def primary_key(cls): + return ("order_id", "product_id") order_id: int product_id: int @@ -123,8 +125,13 @@ from datetime import datetime, timezone class LegacyPost(TimestampMixin, ActiveRecord): # The semantics base declares no fields; declare them here and point to them - __created_at_field__ = "creation_date" - __updated_at_field__ = "last_modified" + @classmethod + def created_at_field(cls): + return "creation_date" + + @classmethod + def updated_at_field(cls): + return "last_modified" creation_date: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) last_modified: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) @@ -173,7 +180,10 @@ from datetime import datetime, timezone from rhosocial.activerecord.field import DefaultTimestampMixin, UtcTimestampMixin class Event(UtcTimestampMixin, DefaultTimestampMixin, ActiveRecord): - __table_name__ = "events" + @classmethod + def table_name(cls): + return "events" + occurred_at: datetime event = Event(occurred_at=datetime(2026, 1, 1, 12)) @@ -227,7 +237,9 @@ from typing import Optional from pydantic import Field class LegacyComment(SoftDeleteMixin, ActiveRecord): - __deleted_at_field__ = "deleted" + @classmethod + def deleted_at_field(cls): + return "deleted" deleted: Optional[datetime] = Field(default=None) content: str @@ -271,8 +283,13 @@ from rhosocial.activerecord.base.fields import UseColumn, UseConstraint class Article(OptimisticLockMixin, ActiveRecord): - __version_field__ = "row_version" - __version_increment_by__ = 2 # optional, default 1 + @classmethod + def version_field(cls): + return "row_version" + + @classmethod + def version_increment_by(cls): + return 2 # optional, default 1 row_version: Annotated[ int, UseColumn("row_ver"), UseConstraint(ColumnNotNullConstraint) diff --git a/docs/en_US/modeling/readonly_models.md b/docs/en_US/modeling/readonly_models.md index ded550f60..cf6018c60 100644 --- a/docs/en_US/modeling/readonly_models.md +++ b/docs/en_US/modeling/readonly_models.md @@ -34,7 +34,10 @@ from rhosocial.activerecord.field import ReadOnlyMixin class UserAnalytics(ActiveRecord, ReadOnlyMixin): """Read-only view of the users table on the analytics replica.""" - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: Optional[int] = None name: str email: str @@ -101,8 +104,14 @@ An explicit `__read_only__ = False` turns the gate back off: ```python class SometimesWritable(ActiveRecord, ReadOnlyMixin): - __table_name__ = "records" - __read_only__ = False + @classmethod + def table_name(cls): + return "records" + + @classmethod + def read_only(cls): + return False + ``` The gate reads the **value** returned by `read_only()`, not the type of the @@ -133,7 +142,10 @@ object kind on the DDL source: from rhosocial.activerecord.base import ObjectDeclaration class UserAnalytics(ActiveRecord, ReadOnlyMixin): - __table_name__ = "user_analytics_view" + @classmethod + def table_name(cls): + return "user_analytics_view" + id: Optional[int] = None name: str diff --git a/docs/en_US/querying/cheatsheet.md b/docs/en_US/querying/cheatsheet.md index 5b974fa55..0549b0ba6 100644 --- a/docs/en_US/querying/cheatsheet.md +++ b/docs/en_US/querying/cheatsheet.md @@ -371,7 +371,10 @@ Models using `CompositePKMixin` declare `__primary_key__` as a tuple of column n from rhosocial.activerecord.field import CompositePKMixin class OrderItem(CompositePKMixin, ActiveRecord): - __primary_key__ = ("order_id", "product_id") + @classmethod + def primary_key(cls): + return ("order_id", "product_id") + order_id: int product_id: int quantity: int diff --git a/docs/en_US/relationships/definitions.md b/docs/en_US/relationships/definitions.md index 4bf91f105..39087400e 100644 --- a/docs/en_US/relationships/definitions.md +++ b/docs/en_US/relationships/definitions.md @@ -244,7 +244,9 @@ from rhosocial.activerecord.field import CompositePKMixin from rhosocial.activerecord.relation import BelongsTo, HasMany class Order(ActiveRecord): - __primary_key__ = ("order_id",) + @classmethod + def primary_key(cls): + return ("order_id",) order_id: int items: ClassVar[HasMany['OrderItem']] = HasMany( @@ -253,7 +255,9 @@ class Order(ActiveRecord): ) class OrderItem(CompositePKMixin, ActiveRecord): - __primary_key__ = ("order_id", "product_id") + @classmethod + def primary_key(cls): + return ("order_id", "product_id") order_id: int product_id: int diff --git a/docs/en_US/relationships/eager_loading.md b/docs/en_US/relationships/eager_loading.md index 0d24e42e5..ca9c5e579 100644 --- a/docs/en_US/relationships/eager_loading.md +++ b/docs/en_US/relationships/eager_loading.md @@ -12,14 +12,20 @@ from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.relation import HasMany, BelongsTo class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: int name: str posts: ClassVar = HasMany('Post', inverse_of='user') class Post(ActiveRecord): - __table_name__ = "posts" + @classmethod + def table_name(cls): + return "posts" + id: int title: str user_id: int diff --git a/docs/en_US/testing/backend_testing.md b/docs/en_US/testing/backend_testing.md index 550ad6e6b..bb4485d48 100644 --- a/docs/en_US/testing/backend_testing.md +++ b/docs/en_US/testing/backend_testing.md @@ -12,7 +12,10 @@ When you build a query on a model that has not been configured with any backend, from rhosocial.activerecord.model import ActiveRecord class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: int username: str email: str diff --git a/docs/en_US/testing/dummy.md b/docs/en_US/testing/dummy.md index ef7cf6ca7..59fe6f385 100644 --- a/docs/en_US/testing/dummy.md +++ b/docs/en_US/testing/dummy.md @@ -21,7 +21,10 @@ from rhosocial.activerecord.model import ActiveRecord class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: int username: str email: str diff --git a/docs/zh_CN/connection/connection_pool.md b/docs/zh_CN/connection/connection_pool.md index 97feb2f9e..aa29d9370 100644 --- a/docs/zh_CN/connection/connection_pool.md +++ b/docs/zh_CN/connection/connection_pool.md @@ -541,7 +541,10 @@ from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.field import IntegerPKMixin class User(IntegerPKMixin, ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: Optional[int] = None name: str email: str @@ -593,7 +596,10 @@ with pool.context(): from rhosocial.activerecord.model import AsyncActiveRecord class AsyncUser(IntegerPKMixin, AsyncActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: Optional[int] = None name: str email: str diff --git a/docs/zh_CN/getting_started/installation.md b/docs/zh_CN/getting_started/installation.md index f666725b8..6ef9d6f65 100644 --- a/docs/zh_CN/getting_started/installation.md +++ b/docs/zh_CN/getting_started/installation.md @@ -143,7 +143,10 @@ from typing import ClassVar # 定义简单模型测试 class TestModel(ActiveRecord): - __table_name__ = "test_table" + @classmethod + def table_name(cls): + return "test_table" + name: str c: ClassVar[FieldProxy] = FieldProxy() diff --git a/docs/zh_CN/introduction/coming_from_frameworks.md b/docs/zh_CN/introduction/coming_from_frameworks.md index 25b9c71a4..d9cb51361 100644 --- a/docs/zh_CN/introduction/coming_from_frameworks.md +++ b/docs/zh_CN/introduction/coming_from_frameworks.md @@ -116,7 +116,10 @@ class User(models.Model): **rhosocial-activerecord:** ```python class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + username: str = Field(max_length=50) email: str ``` diff --git a/docs/zh_CN/introduction/competitor_analysis/django_orm.md b/docs/zh_CN/introduction/competitor_analysis/django_orm.md index 33be9d15b..6af4bcd0e 100644 --- a/docs/zh_CN/introduction/competitor_analysis/django_orm.md +++ b/docs/zh_CN/introduction/competitor_analysis/django_orm.md @@ -32,7 +32,10 @@ from myapp.models import User # 只能在 Django app 中定义 from rhosocial.activerecord.model import ActiveRecord class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: Optional[int] = None name: str @@ -251,14 +254,20 @@ author.post_set.all() # 自动生成的反向名称 ```python # 关系定义更灵活 class Author(ActiveRecord): - __table_name__ = "authors" + @classmethod + def table_name(cls): + return "authors" + id: Optional[int] = None name: str c: ClassVar[FieldProxy] = FieldProxy() posts: ClassVar[HasMany["Post"]] = HasMany(foreign_key="author_id") class Post(ActiveRecord): - __table_name__ = "posts" + @classmethod + def table_name(cls): + return "posts" + id: Optional[int] = None title: str author_id: int diff --git a/docs/zh_CN/introduction/competitor_analysis/peewee.md b/docs/zh_CN/introduction/competitor_analysis/peewee.md index e2f5d028e..6ec515fbd 100644 --- a/docs/zh_CN/introduction/competitor_analysis/peewee.md +++ b/docs/zh_CN/introduction/competitor_analysis/peewee.md @@ -34,7 +34,10 @@ from rhosocial.activerecord.model import ActiveRecord from typing import Optional class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: Optional[int] = None name: str = Field(max_length=100) age: int = 0 @@ -176,7 +179,10 @@ from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.relation import HasMany, BelongsTo class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: Optional[int] = None name: str @@ -184,7 +190,10 @@ class User(ActiveRecord): posts: ClassVar[HasMany["Post"]] = HasMany(foreign_key="author_id") class Post(ActiveRecord): - __table_name__ = "posts" + @classmethod + def table_name(cls): + return "posts" + id: Optional[int] = None title: str author_id: int diff --git a/docs/zh_CN/introduction/competitor_analysis/prisma.md b/docs/zh_CN/introduction/competitor_analysis/prisma.md index e6db8f1b5..19787e53d 100644 --- a/docs/zh_CN/introduction/competitor_analysis/prisma.md +++ b/docs/zh_CN/introduction/competitor_analysis/prisma.md @@ -51,7 +51,10 @@ from rhosocial.activerecord.relation import HasMany, BelongsTo from typing import ClassVar, Optional class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: Optional[int] = None name: str @@ -59,7 +62,10 @@ class User(ActiveRecord): posts: ClassVar[HasMany["Post"]] = HasMany(foreign_key="author_id") class Post(ActiveRecord): - __table_name__ = "posts" + @classmethod + def table_name(cls): + return "posts" + id: Optional[int] = None title: str author_id: int diff --git a/docs/zh_CN/introduction/competitor_analysis/sqlalchemy.md b/docs/zh_CN/introduction/competitor_analysis/sqlalchemy.md index e55ca4cd0..f90f6ffa1 100644 --- a/docs/zh_CN/introduction/competitor_analysis/sqlalchemy.md +++ b/docs/zh_CN/introduction/competitor_analysis/sqlalchemy.md @@ -124,7 +124,10 @@ class User(Base): ```python # 原生 Pydantic 风格 class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: Optional[int] = None # 主键 name: str = Field(max_length=50) # Pydantic Field diff --git a/docs/zh_CN/introduction/competitor_analysis/sqlmodel.md b/docs/zh_CN/introduction/competitor_analysis/sqlmodel.md index 7a04003e8..fb9b3a973 100644 --- a/docs/zh_CN/introduction/competitor_analysis/sqlmodel.md +++ b/docs/zh_CN/introduction/competitor_analysis/sqlmodel.md @@ -184,7 +184,10 @@ from rhosocial.activerecord.relation import HasMany, BelongsTo from typing import ClassVar, Optional class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: Optional[int] = None name: str = Field(max_length=100) @@ -192,7 +195,10 @@ class User(ActiveRecord): posts: ClassVar[HasMany["Post"]] = HasMany(foreign_key="author_id") class Post(ActiveRecord): - __table_name__ = "posts" + @classmethod + def table_name(cls): + return "posts" + id: Optional[int] = None title: str author_id: int diff --git a/docs/zh_CN/introduction/competitor_analysis/tortoise_orm.md b/docs/zh_CN/introduction/competitor_analysis/tortoise_orm.md index fc4929792..952347b87 100644 --- a/docs/zh_CN/introduction/competitor_analysis/tortoise_orm.md +++ b/docs/zh_CN/introduction/competitor_analysis/tortoise_orm.md @@ -85,7 +85,10 @@ from rhosocial.activerecord.relation import HasMany, BelongsTo from typing import ClassVar, Optional class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: Optional[int] = None name: str = Field(max_length=100) age: int = 0 @@ -94,7 +97,10 @@ class User(ActiveRecord): posts: ClassVar[HasMany["Post"]] = HasMany(foreign_key="author_id") class Post(ActiveRecord): - __table_name__ = "posts" + @classmethod + def table_name(cls): + return "posts" + id: Optional[int] = None title: str = Field(max_length=200) author_id: int diff --git a/docs/zh_CN/logging/README.md b/docs/zh_CN/logging/README.md index 512d8d01a..14b14402d 100644 --- a/docs/zh_CN/logging/README.md +++ b/docs/zh_CN/logging/README.md @@ -73,13 +73,19 @@ ActiveRecord.__logging_config__ = LoggingConfig( ) class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: Optional[int] = None username: str password: str class AuditUser(ActiveRecord): - __table_name__ = "audit_users" + @classmethod + def table_name(cls): + return "audit_users" + __logging_config__ = LoggingConfig(log_data_mode=LogDataMode.KEYS_ONLY) id: Optional[int] = None username: str diff --git a/docs/zh_CN/logging/data_summarization.md b/docs/zh_CN/logging/data_summarization.md index eff0a695f..e372fb3ce 100644 --- a/docs/zh_CN/logging/data_summarization.md +++ b/docs/zh_CN/logging/data_summarization.md @@ -124,7 +124,10 @@ from rhosocial.activerecord.logging import LoggingConfig, LogDataMode from rhosocial.activerecord.model import ActiveRecord class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + __logging_config__ = LoggingConfig(log_data_mode=LogDataMode.SUMMARY) id: Optional[int] = None username: str diff --git a/docs/zh_CN/logging/per_logger_config.md b/docs/zh_CN/logging/per_logger_config.md index 31f51be5c..6409995a1 100644 --- a/docs/zh_CN/logging/per_logger_config.md +++ b/docs/zh_CN/logging/per_logger_config.md @@ -19,7 +19,10 @@ from rhosocial.activerecord.model import ActiveRecord class User(ActiveRecord): __logger_name__ = "myapp.models.user" - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + ``` `__logger_name__` 只改变日志输出目标;data payload 的展示规则仍来自该模型所属的 `LoggingConfig`。 @@ -46,7 +49,10 @@ ActiveRecord.__logging_config__ = model_config ```python class AuditUser(ActiveRecord): - __table_name__ = "audit_users" + @classmethod + def table_name(cls): + return "audit_users" + __logging_config__ = LoggingConfig(log_data_mode=LogDataMode.HIDDEN) ``` diff --git a/docs/zh_CN/modeling/batch_processing.md b/docs/zh_CN/modeling/batch_processing.md index b22f632e1..52aa581b4 100644 --- a/docs/zh_CN/modeling/batch_processing.md +++ b/docs/zh_CN/modeling/batch_processing.md @@ -19,7 +19,10 @@ from rhosocial.activerecord.model import ActiveRecord class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None name: str diff --git a/docs/zh_CN/modeling/best_practices.md b/docs/zh_CN/modeling/best_practices.md index b69a92ad9..c4a9eca35 100644 --- a/docs/zh_CN/modeling/best_practices.md +++ b/docs/zh_CN/modeling/best_practices.md @@ -584,7 +584,10 @@ from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: Optional[int] = None name: str email: str @@ -646,11 +649,17 @@ class UserFieldsMixin: email: str class User(UserFieldsMixin, ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + c: ClassVar[FieldProxy] = FieldProxy() class UserMetric(UserFieldsMixin, ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + c: ClassVar[FieldProxy] = FieldProxy() ``` @@ -665,10 +674,15 @@ class UserFieldsMixin(BaseModel): email: str class User(UserFieldsMixin, ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" class UserMetric(UserFieldsMixin, ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + ``` 两种写法的运行时行为完全相同。分别为各类配置独立连接: diff --git a/docs/zh_CN/modeling/bulk_operations.md b/docs/zh_CN/modeling/bulk_operations.md index d7079f903..02fd76f4d 100644 --- a/docs/zh_CN/modeling/bulk_operations.md +++ b/docs/zh_CN/modeling/bulk_operations.md @@ -12,7 +12,10 @@ from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.base.bulk_operations import BulkOperationsMixin class User(BulkOperationsMixin, ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: int name: str email: str @@ -84,7 +87,10 @@ User.bulk_create(users, batch_size=20, commit_mode=BatchCommitMode.PER_BATCH) from rhosocial.activerecord.base.bulk_operations import AsyncBulkOperationsMixin class AsyncUser(AsyncBulkOperationsMixin, ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: int name: str email: str diff --git a/docs/zh_CN/modeling/ddl_source.md b/docs/zh_CN/modeling/ddl_source.md index f980e424c..ba2c4dc6f 100644 --- a/docs/zh_CN/modeling/ddl_source.md +++ b/docs/zh_CN/modeling/ddl_source.md @@ -7,7 +7,9 @@ from rhosocial.activerecord.base import DDLSource from rhosocial.activerecord.model import ActiveRecord class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" id: int name: str @@ -83,7 +85,10 @@ class BackendSettingHandler(DDLAnnotationHandler): metadata.add_column_options(annotation.option) class Report(ActiveRecord): - __table_name__ = "reports" + @classmethod + def table_name(cls): + return "reports" + _feature_handlers = [BackendSettingHandler] id: int @@ -231,9 +236,17 @@ from rhosocial.activerecord.model import ActiveRecord class Account(ActiveRecord): - __table_name__ = "accounts" - __schema_name__ = "app" - __primary_key__ = "account_id" + @classmethod + def table_name(cls): + return "accounts" + + @classmethod + def schema_name(cls): + return "app" + + @classmethod + def primary_key(cls): + return "account_id" account_id: Annotated[int, UseColumn("id")] display_name: Annotated[ diff --git a/docs/zh_CN/modeling/ddl_source_examples.md b/docs/zh_CN/modeling/ddl_source_examples.md index 15d06c50b..6dace3649 100644 --- a/docs/zh_CN/modeling/ddl_source_examples.md +++ b/docs/zh_CN/modeling/ddl_source_examples.md @@ -48,7 +48,9 @@ from rhosocial.activerecord.model import ActiveRecord class PlainAccount(ActiveRecord): - __table_name__ = "plain_accounts" + @classmethod + def table_name(cls): + return "plain_accounts" id: int name: str @@ -151,7 +153,9 @@ from rhosocial.activerecord.model import ActiveRecord class CoreDeclared(ActiveRecord): - __table_name__ = "core_declared" + @classmethod + def table_name(cls): + return "core_declared" id: Annotated[int, UseColumn("record_id")] name: Annotated[ @@ -504,7 +508,9 @@ selected = bind(selected, dialect) ```python class ForeignOnly(ActiveRecord): - __table_name__ = "foreign_only" + @classmethod + def table_name(cls): + return "foreign_only" id: int value: Annotated[str, UseSqlType(ForeignType())] diff --git a/docs/zh_CN/modeling/derived_fields.md b/docs/zh_CN/modeling/derived_fields.md index ab0c6e4da..eaf414695 100644 --- a/docs/zh_CN/modeling/derived_fields.md +++ b/docs/zh_CN/modeling/derived_fields.md @@ -19,7 +19,10 @@ from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.base.fields import DerivedField class Product(ActiveRecord): - __table_name__ = "products" + @classmethod + def table_name(cls): + return "products" + id: int name: str price: Decimal @@ -40,7 +43,10 @@ from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.base.fields import DerivedField class Product(ActiveRecord): - __table_name__ = "products" + @classmethod + def table_name(cls): + return "products" + id: int name: str price: Decimal diff --git a/docs/zh_CN/modeling/fields.md b/docs/zh_CN/modeling/fields.md index ba2f8ce1c..c92178812 100644 --- a/docs/zh_CN/modeling/fields.md +++ b/docs/zh_CN/modeling/fields.md @@ -59,7 +59,9 @@ from rhosocial.activerecord.backend.expression.types import ( ) class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" # 显式指定 VARCHAR(100),而非默认的 TEXT username: Annotated[str, UseSqlType(VarCharType(length=100))] @@ -201,7 +203,10 @@ from typing import ClassVar, Optional from rhosocial.activerecord.base import FieldProxy class Employee(ActiveRecord): - __table_name__ = "employees" + @classmethod + def table_name(cls): + return "employees" + id: int name: str manager_id: Optional[int] # 指向同一张表的 id diff --git a/docs/zh_CN/modeling/keyless_models.md b/docs/zh_CN/modeling/keyless_models.md index d3fa397d1..dc7f40205 100644 --- a/docs/zh_CN/modeling/keyless_models.md +++ b/docs/zh_CN/modeling/keyless_models.md @@ -15,8 +15,13 @@ from typing import Optional from rhosocial.activerecord.model import ActiveRecord class AuditLog(ActiveRecord): - __table_name__ = "audit_log" - __primary_key__ = None + @classmethod + def table_name(cls): + return "audit_log" + + @classmethod + def primary_key(cls): + return None event: str level: str @@ -107,15 +112,24 @@ AuditLog.find_one(AuditLog.c.event == "a") ```python class KeylessWritable(ActiveRecord): - __primary_key__ = None + @classmethod + def primary_key(cls): + return None + ... class ReadOnlyWithKey(ActiveRecord, ReadOnlyMixin): - __table_name__ = "records" + @classmethod + def table_name(cls): + return "records" + ... class KeylessReadOnly(ActiveRecord, ReadOnlyMixin): - __primary_key__ = None + @classmethod + def primary_key(cls): + return None + ... ``` diff --git a/docs/zh_CN/modeling/mixins.md b/docs/zh_CN/modeling/mixins.md index f82136c64..5c2e9f87b 100644 --- a/docs/zh_CN/modeling/mixins.md +++ b/docs/zh_CN/modeling/mixins.md @@ -76,7 +76,9 @@ UUID 主键在保存前由 Python 生成,不依赖数据库的自增机制。 from rhosocial.activerecord.field import CompositePKMixin class OrderItem(CompositePKMixin, ActiveRecord): - __primary_key__ = ("order_id", "product_id") + @classmethod + def primary_key(cls): + return ("order_id", "product_id") order_id: int product_id: int @@ -123,8 +125,13 @@ from datetime import datetime, timezone class LegacyPost(TimestampMixin, ActiveRecord): # 语义基类不声明字段,这里由模型自行声明,并指向它们 - __created_at_field__ = "creation_date" - __updated_at_field__ = "last_modified" + @classmethod + def created_at_field(cls): + return "creation_date" + + @classmethod + def updated_at_field(cls): + return "last_modified" creation_date: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) last_modified: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) @@ -170,7 +177,10 @@ from datetime import datetime, timezone from rhosocial.activerecord.field import DefaultTimestampMixin, UtcTimestampMixin class Event(UtcTimestampMixin, DefaultTimestampMixin, ActiveRecord): - __table_name__ = "events" + @classmethod + def table_name(cls): + return "events" + occurred_at: datetime event = Event(occurred_at=datetime(2026, 1, 1, 12)) @@ -220,7 +230,9 @@ from typing import Optional from pydantic import Field class LegacyComment(SoftDeleteMixin, ActiveRecord): - __deleted_at_field__ = "deleted" + @classmethod + def deleted_at_field(cls): + return "deleted" deleted: Optional[datetime] = Field(default=None) content: str @@ -264,8 +276,13 @@ from rhosocial.activerecord.base.fields import UseColumn, UseConstraint class Article(OptimisticLockMixin, ActiveRecord): - __version_field__ = "row_version" - __version_increment_by__ = 2 # 可选,默认 1 + @classmethod + def version_field(cls): + return "row_version" + + @classmethod + def version_increment_by(cls): + return 2 # 可选,默认 1 row_version: Annotated[ int, UseColumn("row_ver"), UseConstraint(ColumnNotNullConstraint) diff --git a/docs/zh_CN/modeling/readonly_models.md b/docs/zh_CN/modeling/readonly_models.md index 68231c8ad..31e90c8c0 100644 --- a/docs/zh_CN/modeling/readonly_models.md +++ b/docs/zh_CN/modeling/readonly_models.md @@ -32,7 +32,10 @@ from rhosocial.activerecord.field import ReadOnlyMixin class UserAnalytics(ActiveRecord, ReadOnlyMixin): """只读视图——连接分析副本上的 users 表。""" - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: Optional[int] = None name: str email: str @@ -94,8 +97,14 @@ UserAnalytics.find_one({"email": "alice@example.com"}) ```python class SometimesWritable(ActiveRecord, ReadOnlyMixin): - __table_name__ = "records" - __read_only__ = False + @classmethod + def table_name(cls): + return "records" + + @classmethod + def read_only(cls): + return False + ``` 门禁读取的是 `read_only()` 的**返回值**,而不是接收者的类型。模型可以实现 @@ -125,7 +134,10 @@ PolicyDriven.refuse_read_only("save") # 允许 from rhosocial.activerecord.base import ObjectDeclaration class UserAnalytics(ActiveRecord, ReadOnlyMixin): - __table_name__ = "user_analytics_view" + @classmethod + def table_name(cls): + return "user_analytics_view" + id: Optional[int] = None name: str diff --git a/docs/zh_CN/querying/cheatsheet.md b/docs/zh_CN/querying/cheatsheet.md index a4085743f..674a93240 100644 --- a/docs/zh_CN/querying/cheatsheet.md +++ b/docs/zh_CN/querying/cheatsheet.md @@ -375,7 +375,10 @@ results = User.query().distinct(User.c.country, User.c.city).all() from rhosocial.activerecord.field import CompositePKMixin class OrderItem(CompositePKMixin, ActiveRecord): - __primary_key__ = ("order_id", "product_id") + @classmethod + def primary_key(cls): + return ("order_id", "product_id") + order_id: int product_id: int quantity: int diff --git a/docs/zh_CN/relationships/definitions.md b/docs/zh_CN/relationships/definitions.md index 54a759ad8..93d09d475 100644 --- a/docs/zh_CN/relationships/definitions.md +++ b/docs/zh_CN/relationships/definitions.md @@ -180,7 +180,9 @@ from rhosocial.activerecord.field import CompositePKMixin from rhosocial.activerecord.relation import BelongsTo, HasMany class Order(ActiveRecord): - __primary_key__ = ("order_id",) + @classmethod + def primary_key(cls): + return ("order_id",) order_id: int items: ClassVar[HasMany['OrderItem']] = HasMany( @@ -189,7 +191,9 @@ class Order(ActiveRecord): ) class OrderItem(CompositePKMixin, ActiveRecord): - __primary_key__ = ("order_id", "product_id") + @classmethod + def primary_key(cls): + return ("order_id", "product_id") order_id: int product_id: int diff --git a/docs/zh_CN/relationships/eager_loading.md b/docs/zh_CN/relationships/eager_loading.md index a32ddc7e5..85e14acf9 100644 --- a/docs/zh_CN/relationships/eager_loading.md +++ b/docs/zh_CN/relationships/eager_loading.md @@ -12,14 +12,20 @@ from rhosocial.activerecord.model import ActiveRecord from rhosocial.activerecord.relation import HasMany, BelongsTo class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: int name: str posts: ClassVar = HasMany('Post', inverse_of='user') class Post(ActiveRecord): - __table_name__ = "posts" + @classmethod + def table_name(cls): + return "posts" + id: int title: str user_id: int diff --git a/docs/zh_CN/testing/dummy.md b/docs/zh_CN/testing/dummy.md index 07d2e04ab..01a86a5df 100644 --- a/docs/zh_CN/testing/dummy.md +++ b/docs/zh_CN/testing/dummy.md @@ -21,7 +21,10 @@ from rhosocial.activerecord.model import ActiveRecord class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: int username: str email: str diff --git a/scripts/d5_migrate_declarations.py b/scripts/d5_migrate_declarations.py new file mode 100644 index 000000000..fa0be55ac --- /dev/null +++ b/scripts/d5_migrate_declarations.py @@ -0,0 +1,141 @@ +# scripts/d5_migrate_declarations.py (temporary; delete after the wave) +"""D5 sweep: class-body dunder declarations -> accessor method overrides. + +AST-located, not regex-matched, so a docstring example or a local variable +that happens to spell a dunder is never rewritten. Namespace-dict entries +(for ``type(...)`` dynamic models) take the ``classmethod(lambda cls: ...)`` +form. +""" +import ast +import sys +from pathlib import Path + +ROOTS = [Path("src"), Path("tests")] +EXCLUDE_RELPATHS = { + "src/rhosocial/activerecord/field/version.py", + "src/rhosocial/activerecord/field/read_only.py", + "src/rhosocial/activerecord/field/soft_delete.py", + "src/rhosocial/activerecord/field/timestamp.py", + "src/rhosocial/activerecord/interface/model.py", + "src/rhosocial/activerecord/field/composite_pk.py", + "src/rhosocial/activerecord/base/ddl.py", + # Its _LOCATOR_LANDINGS table spells the dunders as dict keys on purpose. + "src/rhosocial/activerecord/field/field_config.py", +} + +MAP = { + "__table_name__": "table_name", + "__schema_name__": "schema_name", + "__primary_key__": "primary_key", + "__pk_auto_generated__": "pk_auto_generated", + "__deleted_at_field__": "deleted_at_field", + "__created_at_field__": "created_at_field", + "__updated_at_field__": "updated_at_field", + "__version_field__": "version_field", + "__version_increment_by__": "version_increment_by", + "__read_only__": "read_only", +} + + +def value_text(seg: str) -> str: + """Strip a trailing comma from a captured value.""" + seg = seg.strip() + if seg.endswith(","): + seg = seg[:-1].rstrip() + return seg + + +def convert_file(path: Path, dry: bool) -> int: + rel = path.as_posix() + if rel in EXCLUDE_RELPATHS or "__pycache__" in rel: + return 0 + raw = path.read_bytes() + source = raw.decode("utf-8") + crlf = "\r\n" in source + try: + tree = ast.parse(source) + except SyntaxError: + print(f"SKIP (syntax): {rel}") + return 0 + + targets = [] # (lineno, dunder, replacement_lines) + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + for stmt in node.body: + if isinstance(stmt, ast.Assign) and len(stmt.targets) == 1: + t = stmt.targets[0] + if isinstance(t, ast.Name) and t.id in MAP: + targets.append((stmt.lineno, t.id)) + elif isinstance(stmt, ast.AnnAssign): + t = stmt.target + if isinstance(t, ast.Name) and t.id in MAP: + targets.append((stmt.lineno, t.id)) + elif isinstance(node, ast.Dict): + for key, value in zip(node.keys, node.values): + if ( + isinstance(key, ast.Constant) + and isinstance(key.value, str) + and key.value in MAP + ): + targets.append((key.lineno, key.value)) + + if not targets: + return 0 + lines = source.split("\n") + out = [] + changed = 0 + pending = {lineno: dunder for lineno, dunder in targets} + for i, line in enumerate(lines, start=1): + if i in pending: + dunder = pending[i] + method = MAP[dunder] + indent = line[: len(line) - len(line.lstrip())] + if line.lstrip().startswith('"'): + # Namespace dict entry: value runs to end of line (checked + # single-line only; the sweep aborts on anything else). + raw = line.split(":", 1)[1].rstrip() + trailing = "," if raw.endswith(",") else "" + value = value_text(raw) + out.append( + f'{indent}"{method}": classmethod(' + f"lambda cls: {value}){trailing}" + ) + else: + raw = line.split("=", 1)[1] if "=" in line else "" + value = value_text(raw) + out.append(f"{indent}@classmethod") + out.append(f"{indent}def {method}(cls):") + out.append(f"{indent} return {value}") + # Separate the new method from whatever follows only when the + # source offered no blank line of its own, so the rewritten + # file keeps the original spacing. + nxt = lines[i] if i < len(lines) else "" + if nxt.strip(): + out.append("") + changed += 1 + continue + out.append(line) + if changed and not dry: + out_text = "\n".join(out) + if crlf: + out_text = out_text.replace("\n", "\r\n") + path.write_bytes(out_text.encode("utf-8")) + return changed + + +def main() -> None: + args = [a for a in sys.argv[1:] if not a.startswith("--")] + dry = "--dry" in sys.argv + roots = [Path(a) for a in args] if args else ROOTS + total = files = 0 + for root in roots: + for path in sorted(root.rglob("*.py")): + n = convert_file(path, dry) + if n: + files += 1 + total += n + print(f"{'would convert' if dry else 'converted'} {total} declarations in {files} files") + + +if __name__ == "__main__": + main() diff --git a/scripts/d5_migrate_docs.py b/scripts/d5_migrate_docs.py new file mode 100644 index 000000000..783e883d9 --- /dev/null +++ b/scripts/d5_migrate_docs.py @@ -0,0 +1,82 @@ +# scripts/d5_migrate_docs.py +"""Rewrite declaration examples in Markdown: dunder assignment -> method override. + +Only the *assignment* form is rewritten (``__table_name__ = "x"`` inside a +code block). Prose that merely mentions a dunder name is left alone: those +sentences need a human decision, and the rules doc already states the +policy they should be read against. +""" +import re +import sys +from pathlib import Path + +MAP = { + "__table_name__": "table_name", + "__schema_name__": "schema_name", + "__primary_key__": "primary_key", + "__pk_auto_generated__": "pk_auto_generated", + "__deleted_at_field__": "deleted_at_field", + "__created_at_field__": "created_at_field", + "__updated_at_field__": "updated_at_field", + "__version_field__": "version_field", + "__version_increment_by__": "version_increment_by", + "__read_only__": "read_only", +} + + +def convert(path: Path, dry: bool) -> int: + raw = path.read_bytes().decode("utf-8") + crlf = "\r\n" in raw + text = raw.replace("\r\n", "\n") + lines = text.split("\n") + pending = {} + for dunder in MAP: + pattern = re.compile( + r"(?m)^(\s*)" + re.escape(dunder) + r"\s*=\s*(.*?)\s*$" + ) + for index, line in enumerate(lines): + if index in pending: + continue + match = pattern.match(line) + if match: + pending[index] = (match.group(1), match.group(2), MAP[dunder]) + + if not pending: + return 0 + out = [] + for index, line in enumerate(lines): + if index not in pending: + out.append(line) + continue + indent, value, method = pending[index] + out.append(f"{indent}@classmethod") + out.append(f"{indent}def {method}(cls):") + out.append(f"{indent} return {value}") + nxt = lines[index + 1] if index + 1 < len(lines) else "" + if nxt.strip(): + out.append("") + if not dry: + text = "\n".join(out) + if crlf: + text = text.replace("\n", "\r\n") + path.write_bytes(text.encode("utf-8")) + return len(pending) + + +def main() -> None: + dry = "--dry" in sys.argv + roots = [a for a in sys.argv[1:] if not a.startswith("--")] + files = total = 0 + for root in roots: + for path in sorted(Path(root).rglob("*.md")): + count = convert(path, dry) + if count: + files += 1 + total += count + if dry: + print(f"{path.as_posix()}: {count}") + print(f"{'would rewrite' if dry else 'rewrote'} {total} examples in {files} files") + + +if __name__ == "__main__": + main() diff --git a/scripts/d5_migrate_embedded.py b/scripts/d5_migrate_embedded.py new file mode 100644 index 000000000..be15b7bbd --- /dev/null +++ b/scripts/d5_migrate_embedded.py @@ -0,0 +1,63 @@ +# scripts/d5_migrate_embedded.py (temporary; delete after the wave) +"""Rewrite declaration dunders inside embedded model-source strings. + +The CLI tests generate model modules from a source string, so those +declarations are code inside a string and the AST sweep never sees them. +The rewrite is the same three-line method form, applied to the literal +statement text. +""" +import ast +import re +import sys +from pathlib import Path + +MAP = { + "__table_name__": "table_name", + "__schema_name__": "schema_name", + "__primary_key__": "primary_key", + "__pk_auto_generated__": "pk_auto_generated", + "__read_only__": "read_only", +} + + +def rewrite(path: Path) -> int: + raw = path.read_bytes().decode("utf-8") + crlf = "\r\n" in raw + text = raw.replace("\r\n", "\n") + changed = 0 + for dunder, method in MAP.items(): + pattern = re.compile( + r"(?m)^(\s*)" + re.escape(dunder) + r"\s*=\s*(.*?)\s*$" + ) + + def repl(m: "re.Match") -> str: + indent, value = m.group(1), m.group(2) + return ( + f"{indent}@classmethod\n" + f"{indent}def {method}(cls):\n" + f"{indent} return {value}" + ) + + text, n = pattern.subn(repl, text) + changed += n + if not changed: + return 0 + if crlf: + text = text.replace("\n", "\r\n") + path.write_bytes(text.encode("utf-8")) + return changed + + +def main() -> None: + total = 0 + for arg in sys.argv[1:]: + p = Path(arg) + n = rewrite(p) + if n: + print(f"{p.as_posix()}: {n}") + total += n + print(f"rewrote {total} embedded declarations") + + +if __name__ == "__main__": + main() diff --git a/scripts/d5_normalize_blank_lines.py b/scripts/d5_normalize_blank_lines.py new file mode 100644 index 000000000..1e9ff4e82 --- /dev/null +++ b/scripts/d5_normalize_blank_lines.py @@ -0,0 +1,42 @@ +# scripts/d5_normalize_blank_lines.py (temporary; delete after the wave) +"""Collapse runs of 3+ blank lines left by the declaration sweep.""" +import sys +from pathlib import Path + +def normalize(path: Path) -> int: + raw = path.read_bytes().decode("utf-8") + crlf = "\r\n" in raw + lines = raw.replace("\r\n", "\n").split("\n") + out = [] + blank = 0 + changed = 0 + for line in lines: + if line.strip() == "": + blank += 1 + if blank >= 3: + changed += 1 + continue + else: + blank = 0 + out.append(line) + if changed: + text = "\n".join(out) + if crlf: + text = text.replace("\n", "\r\n") + path.write_bytes(text.encode("utf-8")) + return changed + +def main() -> None: + total = files = 0 + for arg in sys.argv[1:]: + for path in sorted(Path(arg).rglob("*.py")): + if "__pycache__" in path.as_posix(): + continue + n = normalize(path) + if n: + files += 1 + total += n + print(f"collapsed {total} blank lines in {files} files") + +if __name__ == "__main__": + main() diff --git a/scripts/p3_ci_refs.py b/scripts/p3_ci_refs.py new file mode 100644 index 000000000..4871ffe23 --- /dev/null +++ b/scripts/p3_ci_refs.py @@ -0,0 +1,53 @@ +# scripts/p3_ci_refs.py +"""Point every workflow's cross-repo install ref at the declaration-surface branch. + +The family is tested by installing the other repositories from a ref: while a +feature spans core, testsuite and the backends, those refs have to name the +matching branch or the run tests a mixture of two revisions. +""" +import re +import subprocess +from pathlib import Path + +ROOT = Path("D:/Projects/rhosocial") +BRANCH = "feature/declaration-surface" + +REPOS = [ + "python-activerecord", + "python-activerecord-testsuite", + "python-activerecord-mysql", + "python-activerecord-postgres", + "python-activerecord-mariadb", + "python-activerecord-sqlserver", + "python-activerecord-oracle", + "python-activerecord-firebird", + "python-activerecord-clickhouse", + "python-activerecord-bigquery", + "python-activerecord-snowflake", +] + +# Only refs that name one of the family's repositories are rewritten. +PATTERN = re.compile( + r"(python-activerecord(?:-testsuite)?\.git@)([^\s\"#]+)" +) + + +def main() -> None: + for name in REPOS: + repo = ROOT / name + workflows = repo / ".github" / "workflows" + if not workflows.exists(): + print(f"{name}: no workflows") + continue + changed = 0 + for path in sorted(workflows.glob("*.yml")): + raw = path.read_bytes().decode("utf-8") + text, count = PATTERN.subn(lambda m: m.group(1) + BRANCH, raw) + if count: + path.write_bytes(text.encode("utf-8")) + changed += count + print(f"{name}: {changed} refs -> {BRANCH}") + + +if __name__ == "__main__": + main() diff --git a/scripts/p3_fix_reads_and_writes.py b/scripts/p3_fix_reads_and_writes.py new file mode 100644 index 000000000..594a0ce9f --- /dev/null +++ b/scripts/p3_fix_reads_and_writes.py @@ -0,0 +1,95 @@ +# scripts/p3_fix_reads_and_writes.py +"""Rewrite the two forms the AST sweep could not see. + +The sweep rewrote *class-body* declarations. Two other forms remain in +backend repositories: + +1. a read such as ``Indexed.__table_name__`` -- a consumer outside the + framework, which now has to call the accessor; +2. a runtime assignment such as ``Author.__table_name__ = "archive"`` -- + a dynamic declaration, which is expressed by installing a classmethod + on the model, the same way an override would be declared in a class body. + +Reads are rewritten first: an assignment's left side is not a read, and +matching it as one would turn ``X.__table_name__ = v`` into the invalid +``X.table_name() = v``. +""" +import re +import sys +from pathlib import Path + +MAP = { + "__table_name__": "table_name", + "__schema_name__": "schema_name", + "__primary_key__": "primary_key", + "__pk_auto_generated__": "pk_auto_generated", + "__read_only__": "read_only", + "__deleted_at_field__": "deleted_at_field", + "__created_at_field__": "created_at_field", + "__updated_at_field__": "updated_at_field", + "__version_field__": "version_field", + "__version_increment_by__": "version_increment_by", +} + +ASSIGN = re.compile( + r"^(\s*)([A-Za-z_][A-Za-z0-9_.]*)\.(__[a-z_]+__)\s*=\s*(.*)$", + re.MULTILINE, +) +READ = re.compile( + r"([A-Za-z_][A-Za-z0-9_.]*)\.(__[a-z_]+__)(?!\s*=\s*)(?![A-Za-z0-9_])" +) + + +def convert(path: Path, dry: bool) -> int: + raw = path.read_bytes().decode("utf-8") + crlf = "\r\n" in raw + text = raw.replace("\r\n", "\n") + count = 0 + + def read_repl(match: "re.Match") -> str: + nonlocal count + owner, dunder = match.groups() + method = MAP.get(dunder) + if method is None: + return match.group(0) + count += 1 + return f"{owner}.{method}()" + + text = READ.sub(read_repl, text) + + def assign_repl(match: "re.Match") -> str: + nonlocal count + indent, owner, dunder, value = match.groups() + method = MAP.get(dunder) + if method is None: + return match.group(0) + count += 1 + return f"{indent}{owner}.{method} = classmethod(lambda cls: {value})" + + text = ASSIGN.sub(assign_repl, text) + + if count and not dry: + if crlf: + text = text.replace("\n", "\r\n") + path.write_bytes(text.encode("utf-8")) + return count + + +def main() -> None: + dry = "--dry" in sys.argv + roots = [a for a in sys.argv[1:] if not a.startswith("--")] + files = total = 0 + for root in roots: + for path in sorted(Path(root).rglob("*.py")): + if "__pycache__" in path.as_posix(): + continue + n = convert(path, dry) + if n: + files += 1 + total += n + print(f"{path.as_posix()}: {n}") + print(f"{'would rewrite' if dry else 'rewrote'} {total} occurrences in {files} files") + + +if __name__ == "__main__": + main() diff --git a/src/rhosocial/activerecord/backend/errors.py b/src/rhosocial/activerecord/backend/errors.py index 42674bfa3..a57c724f7 100644 --- a/src/rhosocial/activerecord/backend/errors.py +++ b/src/rhosocial/activerecord/backend/errors.py @@ -124,7 +124,7 @@ def __init__(self, model_name: str, operation: str): self.operation = operation super().__init__( f"{operation} is not allowed on read-only model '{model_name}'. " - f"{model_name} declares __read_only__ = True (ReadOnlyMixin); the framework " + f"{model_name} declares read_only() True (ReadOnlyMixin); the framework " f"refuses framework-level writes for it." ) @@ -210,7 +210,8 @@ class RecordNotFound(RecordError): class UnaddressableRecordError(RecordError): """A keyless model was asked for single-row access by primary key. - ``__primary_key__ = None`` declares a relation with no single-row identity, + ``primary_key()`` returning ``None`` declares a relation with no single-row +identity, so ``find_one(pk)``, ``find_all([pk])``, and updating or deleting by primary key are not expressible. Raised in place of the incidental failures such ``getattr(instance, None)`` would otherwise produce, which surface as @@ -225,7 +226,7 @@ def __init__(self, model_name: str, operation: str): self.operation = operation super().__init__( f"{operation} needs a primary key, but '{model_name}' declares " - f"__primary_key__ = None and so has no single-row identity. " + f"primary_key() None and so has no single-row identity. " f"Select with {model_name}.query().where(...).one() instead, or declare " f"a primary key if the relation really has one." ) diff --git a/src/rhosocial/activerecord/base/__init__.py b/src/rhosocial/activerecord/base/__init__.py index 67ce69c4d..dbfb1f83a 100644 --- a/src/rhosocial/activerecord/base/__init__.py +++ b/src/rhosocial/activerecord/base/__init__.py @@ -18,6 +18,7 @@ UseConstraint, UseGeneratedColumn, UseIndex, + UsePartitionKey, UseSqlType, UseColumnType, ) @@ -61,6 +62,7 @@ "UseConstraint", "UseGeneratedColumn", "UseIndex", + "UsePartitionKey", "UseSqlType", "UseColumnType", "DerivedField", diff --git a/src/rhosocial/activerecord/base/base.py b/src/rhosocial/activerecord/base/base.py index d864771e9..8dfbc68ec 100644 --- a/src/rhosocial/activerecord/base/base.py +++ b/src/rhosocial/activerecord/base/base.py @@ -274,7 +274,7 @@ def _insert_internal(self, data) -> Any: # A keyless model has nothing for the database to generate, so there is # nothing to read back either. Without the addressable() guard the loop # below iterates an empty column list and indexes it. - if self.__class__.__pk_auto_generated__ and self.__class__.addressable(): + if self.__class__.pk_auto_generated() and self.__class__.addressable(): pk_columns = self.primary_key_columns() pk_retrieved = False for col in pk_columns: @@ -653,11 +653,6 @@ def _prepare_find_or_create_inputs( TypeError: A key is not a model field, a value is an expression, or ``defaults`` is neither a mapping nor ``None``. """ - if not lookup: - raise ValueError( - f"{cls.__name__}.find_one_or_create needs at least one lookup field; " - "an empty lookup would return an arbitrary row" - ) if defaults is None: default_values: Dict[str, Any] = {} elif isinstance(defaults, MappingABC): @@ -667,6 +662,30 @@ def _prepare_find_or_create_inputs( f"defaults must be a mapping or None, got {type(defaults).__name__}" ) + declared_key = tuple(cls.natural_key()) + if not lookup and declared_key: + # Declared form: the model says which fields identify a row, and + # the caller supplies their values in ``defaults``. An explicit + # keyword lookup still wins -- declaring a natural key narrows + # what an empty lookup means, it never overrides what a caller + # asked for. + missing = [f for f in declared_key if f not in default_values] + if missing: + raise ValueError( + f"{cls.__name__}.natural_key() names {missing!r}, which " + f"{'is' if len(missing) == 1 else 'are'} absent from " + f"defaults. A declared natural key needs every member's " + f"value: pass them in defaults, or name the whole key as " + f"keywords." + ) + lookup = {f: default_values[f] for f in declared_key} + + if not lookup: + raise ValueError( + f"{cls.__name__}.find_one_or_create needs at least one lookup " + f"field; an empty lookup would return an arbitrary row" + ) + model_fields = cls.model_fields cleaned_lookup: Dict[str, Any] = {} for field, value in lookup.items(): @@ -686,7 +705,7 @@ def _prepare_find_or_create_inputs( ) cleaned_defaults[field] = value - if cls.__pk_auto_generated__: + if cls.pk_auto_generated(): key_fields = [ field for field in cls.primary_key_fields() @@ -959,6 +978,65 @@ def delete(self) -> int: self._trigger_event(ModelEvent.AFTER_DELETE) return affected_rows + def reload(self) -> "BaseActiveRecord": + """Re-read this row from the database and overwrite the instance. + + The counterpart of a refused write: after a stale-record conflict + the server's row is the newer truth, and the local copy has to catch + up before the caller can decide what to do -- retry with merged + values, or abandon the change. Doing that by hand means re-reading by + primary key and copying every column, which is what this does, plus + the bookkeeping: change tracking is reset to the stored values and, + for a model under optimistic locking, the committed version becomes + the new comparison point (``refresh_lock_state``), so the next UPDATE + is not reported stale against a value nobody holds any more. + + A read, so it is allowed on a read-only model. + + Returns: + The same instance, refreshed. + + Raises: + UnaddressableRecordError: The model has no primary key to read by. + RecordNotFound: The row no longer exists. + ValueError: The instance holds no primary-key value to look it up with. + + Example: + try: + record.save() + except StaleRecordError: + record.reload() # take the server's values + record.save() # retry the change + """ + fields = self.__class__.primary_key_fields() + if not fields: + raise UnaddressableRecordError(self.__class__.__name__, "reload") + values = [getattr(self, field, None) for field in fields] + missing = [field for field, value in zip(fields, values) if value is None] + if missing: + raise ValueError( + f"{self.__class__.__name__}.reload() needs the primary key: " + f"{', '.join(missing)} is unset. A row that was never loaded " + f"has nothing to re-read." + ) + lookup = values[0] if len(fields) == 1 else tuple(values) + record = self.__class__.find_one(lookup) + if record is None: + raise RecordNotFound(f"{self.__class__.__name__} {lookup!r}") + self._adopt_values(record) + return self + + def _adopt_values(self, record: "BaseActiveRecord") -> None: + """Copy every column from *record* and reset the local bookkeeping.""" + for field in self.__class__.model_fields: + if hasattr(record, field): + setattr(self, field, getattr(record, field)) + self.reset_tracking() + self._is_from_db = True + refresh = getattr(self, "refresh_lock_state", None) + if callable(refresh): + refresh() + def validate_custom(self) -> None: pass @@ -1237,7 +1315,7 @@ async def _insert_internal(self, data) -> Any: # A keyless model has nothing for the database to generate, so there is # nothing to read back either. Without the addressable() guard the loop # below iterates an empty column list and indexes it. - if self.__class__.__pk_auto_generated__ and self.__class__.addressable(): + if self.__class__.pk_auto_generated() and self.__class__.addressable(): pk_columns = self.primary_key_columns() pk_retrieved = False for col in pk_columns: @@ -1616,11 +1694,6 @@ def _prepare_find_or_create_inputs( TypeError: A key is not a model field, a value is an expression, or ``defaults`` is neither a mapping nor ``None``. """ - if not lookup: - raise ValueError( - f"{cls.__name__}.find_one_or_create needs at least one lookup field; " - "an empty lookup would return an arbitrary row" - ) if defaults is None: default_values: Dict[str, Any] = {} elif isinstance(defaults, MappingABC): @@ -1630,6 +1703,30 @@ def _prepare_find_or_create_inputs( f"defaults must be a mapping or None, got {type(defaults).__name__}" ) + declared_key = tuple(cls.natural_key()) + if not lookup and declared_key: + # Declared form: the model says which fields identify a row, and + # the caller supplies their values in ``defaults``. An explicit + # keyword lookup still wins -- declaring a natural key narrows + # what an empty lookup means, it never overrides what a caller + # asked for. + missing = [f for f in declared_key if f not in default_values] + if missing: + raise ValueError( + f"{cls.__name__}.natural_key() names {missing!r}, which " + f"{'is' if len(missing) == 1 else 'are'} absent from " + f"defaults. A declared natural key needs every member's " + f"value: pass them in defaults, or name the whole key as " + f"keywords." + ) + lookup = {f: default_values[f] for f in declared_key} + + if not lookup: + raise ValueError( + f"{cls.__name__}.find_one_or_create needs at least one lookup " + f"field; an empty lookup would return an arbitrary row" + ) + model_fields = cls.model_fields cleaned_lookup: Dict[str, Any] = {} for field, value in lookup.items(): @@ -1649,7 +1746,7 @@ def _prepare_find_or_create_inputs( ) cleaned_defaults[field] = value - if cls.__pk_auto_generated__: + if cls.pk_auto_generated(): key_fields = [ field for field in cls.primary_key_fields() @@ -1922,6 +2019,65 @@ async def delete(self) -> int: await self._trigger_event(ModelEvent.AFTER_DELETE) return affected_rows + async def reload(self) -> "AsyncBaseActiveRecord": + """Re-read this row from the database and overwrite the instance asynchronously. + + The counterpart of a refused write: after a stale-record conflict + the server's row is the newer truth, and the local copy has to catch + up before the caller can decide what to do -- retry with merged + values, or abandon the change. Doing that by hand means re-reading by + primary key and copying every column, which is what this does, plus + the bookkeeping: change tracking is reset to the stored values and, + for a model under optimistic locking, the committed version becomes + the new comparison point (``refresh_lock_state``), so the next UPDATE + is not reported stale against a value nobody holds any more. + + A read, so it is allowed on a read-only model. + + Returns: + The same instance, refreshed. + + Raises: + UnaddressableRecordError: The model has no primary key to read by. + RecordNotFound: The row no longer exists. + ValueError: The instance holds no primary-key value to look it up with. + + Example: + try: + await record.save() + except StaleRecordError: + await record.reload() # take the server's values + await record.save() # retry the change + """ + fields = self.__class__.primary_key_fields() + if not fields: + raise UnaddressableRecordError(self.__class__.__name__, "reload") + values = [getattr(self, field, None) for field in fields] + missing = [field for field, value in zip(fields, values) if value is None] + if missing: + raise ValueError( + f"{self.__class__.__name__}.reload() needs the primary key: " + f"{', '.join(missing)} is unset. A row that was never loaded " + f"has nothing to re-read." + ) + lookup = values[0] if len(fields) == 1 else tuple(values) + record = await self.__class__.find_one(lookup) + if record is None: + raise RecordNotFound(f"{self.__class__.__name__} {lookup!r}") + self._adopt_values(record) + return self + + def _adopt_values(self, record: "AsyncBaseActiveRecord") -> None: + """Copy every column from *record* and reset the local bookkeeping.""" + for field in self.__class__.model_fields: + if hasattr(record, field): + setattr(self, field, getattr(record, field)) + self.reset_tracking() + self._is_from_db = True + refresh = getattr(self, "refresh_lock_state", None) + if callable(refresh): + refresh() + def validate_custom(self) -> None: pass diff --git a/src/rhosocial/activerecord/base/ddl.py b/src/rhosocial/activerecord/base/ddl.py index c9d6f5a49..303ccdadb 100644 --- a/src/rhosocial/activerecord/base/ddl.py +++ b/src/rhosocial/activerecord/base/ddl.py @@ -4,6 +4,7 @@ from __future__ import annotations import typing +import warnings from dataclasses import dataclass from enum import Enum from typing import ( @@ -33,12 +34,14 @@ ) from .fields import ( DDLAnnotation, + UseColumn, UseColumnAttributes, UseComment, UseConstraint, UseColumnType, UseGeneratedColumn, UseIndex, + UsePartitionKey, UseSqlType, ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ( @@ -113,6 +116,7 @@ class definition, and a caller that needs to know whether a database UseColumnAttributes: ("column_attributes", False), UseComment: ("column_comment", True), UseGeneratedColumn: ("generated_column", True), + UsePartitionKey: ("partition_key", True), } @@ -302,6 +306,7 @@ class DDLAnnotationHandler: UseColumnAttributes, UseComment, UseGeneratedColumn, + UsePartitionKey, ) @classmethod @@ -338,6 +343,255 @@ def handle(cls, new_class: Type[Any]) -> None: if isinstance(annotation, cls.annotation_types): cls.apply(new_class, field_name, annotation, metadata) metadata.mark_handled(annotation) + cls._validate_column_group_declarations(new_class, metadata_by_field) + cls._extract_partition_key(new_class, metadata_by_field) + cls._validate_natural_key(new_class) + + @staticmethod + def _field_column_names(metadata_by_field: Dict[str, Any]) -> Dict[str, str]: + """Resolve each field's database column name at class creation. + + Reads the ``UseColumn`` markers straight from the collected metadata, + so the mapping is complete regardless of handler ordering (the + column-name handler may run after this one). + """ + resolved: Dict[str, str] = {} + for field_name, metadata in metadata_by_field.items(): + column = None + for annotation in getattr(metadata, "annotations", ()) or (): + if isinstance(annotation, UseColumn): + column = annotation.column_name + break + resolved[field_name] = column or field_name + return resolved + + @classmethod + def _validate_column_group_declarations( + cls, + new_class: Type[Any], + metadata_by_field: Dict[str, Any], + ) -> None: + """Validate composite ``UseIndex``/``UseConstraint`` declarations. + + Three checks, all at class creation so a bad declaration fails loudly + instead of silently producing wrong DDL: + + - the anchor of a ``columns`` list is its first element (the + leftmost prefix); + - every listed column names an existing column of the model; + - index names are unique across the field-level annotations and the + table-level ``__table_indexes__`` declarations -- two same-named + index declarations would each ask the DDL generator to build an + index of that name. + """ + column_names = cls._field_column_names(metadata_by_field) + columns_set = set(column_names.values()) + index_name_sources: List[Tuple[str, str]] = [] + for field_name, metadata in metadata_by_field.items(): + anchor_column = column_names[field_name] + for marker in getattr(metadata, "indexes", ()) or (): + if marker.columns is not None: + cls._validate_columns_list( + new_class, "UseIndex", field_name, marker.name, + anchor_column, marker.columns, columns_set, + ) + index_name_sources.append( + (marker.name, f"field-level index on '{field_name}'") + ) + for marker in getattr(metadata, "constraints", ()) or (): + if marker.columns is not None: + cls._validate_columns_list( + new_class, "UseConstraint", field_name, + marker.table_constraint.branch.__class__.__name__, + anchor_column, marker.columns, columns_set, + ) + for index in getattr(new_class, "__table_indexes__", ()) or (): + name = getattr(index, "name", None) + if name is not None: + index_name_sources.append( + (name, "table-level __table_indexes__ entry") + ) + seen: Dict[str, str] = {} + for name, origin in index_name_sources: + if name in seen: + raise TypeError( + f"{new_class.__name__}: duplicate index name {name!r} " + f"declared by the {seen[name]} and the {origin}. An index " + f"name must have a single source; declare the composite " + f"index once, on its first (leftmost) column." + ) + seen[name] = origin + + @staticmethod + def _validate_columns_list( + new_class: Type[Any], + marker_kind: str, + field_name: str, + marker_label: str, + anchor_column: str, + columns: Sequence[str], + columns_set: Set[str], + ) -> None: + if columns[0] != anchor_column: + raise TypeError( + f"{new_class.__name__}: {marker_kind} {marker_label!r} on " + f"field '{field_name}' anchors column {anchor_column!r}, but " + f"its columns list starts with {columns[0]!r}: the anchor " + f"must be the first (leftmost) column of the declaration." + ) + unknown = [column for column in columns if column not in columns_set] + if unknown: + raise TypeError( + f"{new_class.__name__}: {marker_kind} {marker_label!r} on " + f"field '{field_name}' lists column(s) {unknown!r} that the " + f"model does not define. The columns list may only name " + f"columns of this model." + ) + + @classmethod + def _extract_partition_key( + cls, + new_class: Type[Any], + metadata_by_field: Dict[str, Any], + ) -> None: + """Resolve the partition key from the annotation face or the dunder. + + Four cases (§4 of the governance plan): + + - annotations only: the annotated fields, in ``model_fields`` + declaration order, become ``__partition_key__`` (column names, + UseColumn-aware); + - a direct ``__partition_key__`` declaration only: adopted as written, + after validating it is a plain tuple of existing column names -- + introspection-generated models take this path; + - both: ``TypeError`` (R3) -- one fact, one source; + - neither: nothing is written, and the model is simply not a + partitioned one. + + A partition key that the primary key does not contain is legal on + engines like ClickHouse but surprises a reader expecting a covering + key, so it is announced with a ``UserWarning`` -- a warning, not an + error, because the declaration is not wrong. + """ + annotated: List[str] = [] + column_names = None + for field_name, metadata in metadata_by_field.items(): + for annotation in getattr(metadata, "annotations", ()) or (): + if isinstance(annotation, UsePartitionKey): + if column_names is None: + column_names = cls._field_column_names(metadata_by_field) + annotated.append(column_names[field_name]) + break + declared = new_class.__dict__.get("__partition_key__") + if annotated and declared is not None: + raise TypeError( + f"{new_class.__name__} declares its partition key twice: " + f"UsePartitionKey annotations on {annotated!r} and a " + f"__partition_key__ of {declared!r}. One fact, one source -- " + f"keep the annotations and delete the dunder, or keep the " + f"dunder and drop the markers." + ) + if declared is not None: + cls._validate_partition_key_declaration(new_class, declared) + cls._warn_partition_key_outside_primary_key(new_class, declared) + return + if not annotated: + return + cls._warn_partition_key_outside_primary_key(new_class, tuple(annotated)) + new_class.__partition_key__ = tuple(annotated) + + @staticmethod + def _validate_partition_key_declaration( + new_class: Type[Any], + declared: Any, + ) -> None: + """The first round accepts a plain tuple of existing column names.""" + if not isinstance(declared, tuple): + raise TypeError( + f"{new_class.__name__}: __partition_key__ must be a tuple of " + f"column names, got {type(declared).__name__}. Expression " + f"partition keys (ClickHouse toYYYYMM, BigQuery DATE, ...) " + f"are not accepted yet; wrap the bare column in a tuple even " + f"for a single-column key." + ) + for entry in declared: + if not isinstance(entry, str) or not entry: + raise TypeError( + f"{new_class.__name__}: __partition_key__ entries must be " + f"non-empty column-name strings, got {entry!r}. An " + f"expression key is not accepted yet." + ) + declared_columns = set(declared) + model_columns = set() + for field_name, metadata in ( + getattr(new_class, "__table_ddl_fields__", {}) or {} + ).items(): + model_columns.add( + DDLAnnotationHandler._resolve_field_column(field_name, metadata) + ) + unknown = declared_columns - model_columns + if unknown: + raise TypeError( + f"{new_class.__name__}: __partition_key__ names column(s) " + f"{sorted(unknown)!r} that the model does not define. The " + f"partition key may only name columns of this model." + ) + + @staticmethod + def _resolve_field_column(field_name: str, metadata: Any) -> Optional[str]: + """One field's column name, UseColumn-aware, for ad-hoc checks.""" + for annotation in getattr(metadata, "annotations", ()) or (): + if isinstance(annotation, UseColumn): + return annotation.column_name + return field_name + + @staticmethod + def _warn_partition_key_outside_primary_key( + new_class: Type[Any], + partition_key: Tuple[str, ...], + ) -> None: + if not partition_key: + return + pk_source = getattr(new_class, "primary_key_columns", None) + pk_columns = set(pk_source()) if callable(pk_source) else set() + outside = [column for column in partition_key if column not in pk_columns] + if not outside: + return + warnings.warn( + f"{new_class.__name__} declares partition key " + f"{partition_key!r}, which the primary key does not contain " + f"(outside: {outside!r}). Legal on engines that partition " + f"independently of uniqueness (ClickHouse, BigQuery), but a " + f"primary-key lookup on such a model still touches every " + f"partition -- name the partition column(s) in lookups when " + f"that matters.", + UserWarning, + stacklevel=4, + ) + + @staticmethod + def _validate_natural_key(new_class: Type[Any]) -> None: + """A declared natural key must name fields the model actually has. + + Runs at class creation alongside the DDL declarations: the natural + key is a declaration, and a misspelled member would otherwise + surface only when a lookup on it silently matched nothing. + """ + declared = tuple(getattr(new_class, "natural_key", lambda: ())() or ()) + if not declared: + return + fields = getattr(new_class, "model_fields", {}) or {} + unknown = [field for field in declared if field not in fields] + if unknown: + raise TypeError( + f"{new_class.__name__}: natural_key() names field(s) {unknown!r} " + f"that the model does not define. The natural key may only name " + f"model fields." + ) + if len(set(declared)) != len(declared): + raise TypeError( + f"{new_class.__name__}: natural_key() repeats a field: {declared!r}." + ) @runtime_checkable @@ -377,6 +631,9 @@ def schema_name(self) -> Optional[str]: def primary_key_columns(self) -> Tuple[str, ...]: ... + def partition_key_columns(self) -> Tuple[str, ...]: + ... + def is_composite_pk(self) -> bool: ... @@ -499,11 +756,34 @@ def table_partition(cls) -> Optional[Union[PartitionClause, Sequence[PartitionCl @classmethod def table_indexes(cls) -> Sequence[IndexDefinition]: + cls._validate_ddl_annotations() return list(getattr(cls, "__table_indexes__", ()) or []) + @classmethod + def partition_key_columns(cls) -> Tuple[str, ...]: + """Partition key column names, or ``()`` for a non-partitioned model. + + Read from ``__partition_key__``, which the class-creation handler + extracts from ``UsePartitionKey`` annotations or adopts from a + direct declaration. A column-name tuple, mirroring + ``primary_key_columns()``, because partition scoping is decided + against WHERE trees. + """ + return tuple(getattr(cls, "__partition_key__", ()) or ()) + @classmethod def table_constraints(cls) -> Sequence[TableConstraint]: - declarations = list(getattr(cls, "__table_constraints__", ()) or []) + cls._validate_ddl_annotations() + # Multi-column ``UseConstraint`` markers declare table-level facts + # anchored on a field; they surface here, in field order, ahead of + # the direct-write ``__table_constraints__`` declarations. + declarations: List[TableConstraint] = [] + for metadata in (getattr(cls, "__table_ddl_fields__", {}) or {}).values(): + for marker in getattr(metadata, "constraints", ()) or (): + table_constraint = getattr(marker, "table_constraint", None) + if table_constraint is not None: + declarations.append(table_constraint) + declarations.extend(list(getattr(cls, "__table_constraints__", ()) or [])) source = cast(Any, cls) if source.is_composite_pk(): primary_key_columns = list(source.primary_key_columns()) @@ -525,10 +805,11 @@ def table_constraints(cls) -> Sequence[TableConstraint]: ) else: declarations.append( -TableConstraint( + TableConstraint( cast(Any, None), branch=TablePrimaryKeyConstraint(cast(Any, None), primary_key_columns), - ) ) + ) + ) return declarations @classmethod @@ -553,10 +834,23 @@ def object_type(cls) -> ObjectDeclaration: @classmethod def index_definition(cls, marker: UseIndex, column_name: str) -> IndexDefinition: + """Build an IndexDefinition from a ``UseIndex`` marker. + + An explicit ``columns`` list on the marker is authoritative and must + start with the anchor column (validated again here as a guard for + direct callers; the class-creation check is the primary one). + """ + if marker.columns is not None and marker.columns[0] != column_name: + raise TypeError( + f"UseIndex {marker.name!r} anchors column {column_name!r}, but " + f"its columns list starts with {marker.columns[0]!r}: the " + f"anchor must be the first column of the index." + ) + columns = list(marker.columns) if marker.columns is not None else [column_name] return IndexDefinition( cast(Any, None), name=marker.name, - columns=[column_name], + columns=columns, unique=marker.unique, type=marker.type, partial_condition=cast(Any, marker.partial_condition), @@ -650,7 +944,14 @@ def column_constraints(cls, field: str) -> Sequence[ColumnConstraint]: metadata = cls._field_metadata(field) if metadata is None: return [] - constraints = [marker.constraint for marker in metadata.constraints] + # Multi-column UseConstraint markers declare a table-level fact and + # surface through table_constraints(); they are not column-level + # clauses and must not render inside a column definition. + constraints = [ + marker.constraint + for marker in metadata.constraints + if marker.constraint is not None + ] declared_types = {type(constraint.branch) for constraint in constraints} explicit_not_null = ColumnNotNullConstraint in declared_types explicit_nullable = ColumnNullableConstraint in declared_types diff --git a/src/rhosocial/activerecord/base/fields.py b/src/rhosocial/activerecord/base/fields.py index 745c10e11..1219af079 100644 --- a/src/rhosocial/activerecord/base/fields.py +++ b/src/rhosocial/activerecord/base/fields.py @@ -3,7 +3,7 @@ This module provides classes and functions related to field definitions and annotations. """ -from typing import Any, Callable, List, Optional, Type, Union, TYPE_CHECKING +from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Type, Union, TYPE_CHECKING from ..backend.expression.statements.ddl_table import ( ColumnConstraint, @@ -11,7 +11,11 @@ ColumnForeignKeyConstraint, ColumnGeneratedConstraint, ColumnPrimaryKeyConstraint, + ColumnUniqueConstraint, IndexDefinition, + TableConstraint, + TableForeignKeyConstraint, + TableUniqueConstraint, ) from ..backend.type_adapter import SQLTypeAdapter @@ -298,23 +302,41 @@ def __repr__(self) -> str: class UseIndex(DDLAnnotation): """Marker for ``Annotated[T, UseIndex(name, ...)]``. - Declares a single-column index that the DDL generator will emit inline - with the CREATE TABLE statement (or as a separate CREATE INDEX for backends - that do not support inline indexes). + Declares an index anchored on the annotated field: single-column by + default, composite when ``columns`` is given. The DDL generator will emit + it inline with the CREATE TABLE statement (or as a separate CREATE INDEX + for backends that do not support inline indexes). - For multi-column (composite) indexes, declare ``__table_indexes__`` on the model - class instead. + ``columns`` (optional) is the authoritative column list for a composite + index, given as **database column names** (see :class:`UseColumn` for how + a field may rename its column). The anchor must be the leftmost prefix: + + - the annotated field's column must equal ``columns[0]`` — an anchor + outside the list, or not first, is a contradiction and raises + ``TypeError`` at class creation; + - every column in ``columns`` must name an existing column of the model; + an unknown name raises ``TypeError`` at class creation; + - ``columns`` must list at least two names; for a single-column index + omit it (the anchor alone is the column). + + ``include_columns`` (covering columns, an index-level option) is a + separate parameter and does not participate in the column list above. + + Legacy composite declarations on ``__table_indexes__`` remain readable + for now; new composite indexes should be declared here. Example:: email: Annotated[str, UseIndex("idx_email", unique=True)] - country: Annotated[str, UseIndex("idx_country")] + country: Annotated[str, UseIndex("idx_email_country", + columns=["email", "country"])] """ def __init__( self, name: str, *, + columns: Optional[Sequence[str]] = None, unique: bool = False, type: Optional[str] = None, partial_condition: Optional[Union["SQLPredicate", "Callable"]] = None, @@ -327,6 +349,12 @@ def __init__( if not name: raise ValueError("UseIndex requires a non-empty index name.") self.name = name + # Composite form: the explicit ``columns`` list is authoritative, and + # the anchor (the annotated field) must be ``columns[0]`` — the + # leftmost prefix. Model membership and the anchor position are + # validated at class creation (see DDLAnnotationHandler), where the + # full field set is known. + self.columns: Optional[Tuple[str, ...]] = self._normalize_columns(columns) self.unique = unique self.type = type # May be a ready SQLPredicate or a lazy ``(dialect) -> SQLPredicate`` @@ -342,14 +370,53 @@ def __init__( self.if_exists = if_exists self.concurrent = concurrent + @staticmethod + def _normalize_columns(columns: Optional[Sequence[str]]) -> Optional[Tuple[str, ...]]: + """Normalize the explicit ``columns`` list; validate it in isolation. + + The anchor position and model membership need the class, so they are + checked at class creation instead. + """ + if columns is None: + return None + names = tuple(columns) + if len(names) < 2: + raise TypeError( + "UseIndex columns must list at least two column names; a " + "single-column index omits the parameter and uses the " + "annotated field alone." + ) + for column in names: + if not isinstance(column, str) or not column: + raise TypeError( + f"UseIndex columns entries must be non-empty strings, " + f"got {column!r}." + ) + if len(set(names)) != len(names): + raise TypeError( + f"UseIndex columns repeats a column: {names!r}. An index " + f"over the same column twice is not expressible in SQL." + ) + return names + def to_index_definition(self, column_name: str, dialect: "SQLDialectBase") -> "IndexDefinition": """Build an IndexDefinition that references *column_name*. - The dialect is supplied by the DDL generator at build time. + The dialect is supplied by the DDL generator at build time. When an + explicit ``columns`` list is declared it is authoritative: the anchor + must be ``columns[0]`` (the class-creation check has already run, so + this guard catches direct misuse of the method). """ + if self.columns is not None and self.columns[0] != column_name: + raise TypeError( + f"UseIndex {self.name!r} anchors column {column_name!r}, but " + f"its columns list starts with {self.columns[0]!r}: the " + f"anchor must be the first column of the index." + ) + columns = list(self.columns) if self.columns is not None else [column_name] return IndexDefinition(dialect, name=self.name, - columns=[column_name], + columns=columns, unique=self.unique, type=self.type, partial_condition=self.partial_condition, @@ -362,7 +429,8 @@ def to_index_definition(self, column_name: str, dialect: "SQLDialectBase") -> "I def __repr__(self) -> str: return ( - f"UseIndex({self.name!r}, unique={self.unique!r}, type={self.type!r})" + f"UseIndex({self.name!r}, columns={self.columns!r}, " + f"unique={self.unique!r}, type={self.type!r})" ) @@ -383,13 +451,24 @@ class UseConstraint(DDLAnnotation): - ``ColumnPrimaryKeyConstraint`` — the **only** branch the marker refuses on semantic grounds: the primary key has a single source, the - ``__primary_key__`` constant (accessed through ``primary_key()``); a + ``primary_key()`` accessor, which has a single source; a single-column PK lands on the column, a composite PK becomes a table-level constraint. - For table-level constraints (CHECK spanning multiple columns, composite - UNIQUE, composite FOREIGN KEY), declare ``__table_constraints__`` on the - model class instead. + Composite (multi-column) form: ``columns`` (optional) lists the source + columns -- **database column names** -- of a constraint that spans more + than one column. Only the branches that have a table-level counterpart + accept it (``ColumnUniqueConstraint`` becomes ``TableUniqueConstraint``, + ``ColumnForeignKeyConstraint`` becomes ``TableForeignKeyConstraint``); + other branches are column-level by nature and refuse ``columns``. The + anchor must be ``columns[0]`` (the leftmost prefix), every column must + name an existing column of the model, and violations raise ``TypeError`` + at class creation. A multi-column declaration surfaces through + ``table_constraints()``, not ``column_constraints()``. + + For table-level constraints declared outright (CHECK spanning multiple + columns, composite UNIQUE, composite FOREIGN KEY), ``__table_constraints__`` + on the model class also remains readable for now. Example:: @@ -407,11 +486,17 @@ class UseConstraint(DDLAnnotation): )] """ + _TABLE_LEVEL_BRANCHES = { + ColumnUniqueConstraint: TableUniqueConstraint, + ColumnForeignKeyConstraint: TableForeignKeyConstraint, + } + def __init__( self, branch: type, *, name: Optional[str] = None, + columns: Optional[Sequence[str]] = None, condition: Optional[Union["SQLPredicate", "Callable"]] = None, references: Optional[tuple] = None, value: Any = None, @@ -435,11 +520,54 @@ def __init__( if branch is ColumnPrimaryKeyConstraint: raise ValueError( "UseConstraint does not accept ColumnPrimaryKeyConstraint: the " - "primary key has a single source, the __primary_key__ constant " + "primary key has a single source, the primary_key() accessor " "(or a primary_key() override). A single-column PK lands on the " "column automatically; a composite PK becomes a table-level " "constraint." ) + # Multi-column form: a composite constraint is a table-level fact, so + # the marker builds the table-level branch node and stores it in + # ``table_constraint`` (``constraint`` stays None). The anchor + # position and model membership are validated at class creation, + # where the full field set is known. + self.columns: Optional[Tuple[str, ...]] = self._normalize_columns(columns) + self.table_constraint: Optional[TableConstraint] = None + if self.columns is not None: + table_branch = self._TABLE_LEVEL_BRANCHES.get(branch) + if table_branch is None: + raise TypeError( + f"UseConstraint with columns requires a branch with a " + f"table-level counterpart " + f"({', '.join(b.__name__ for b in self._TABLE_LEVEL_BRANCHES)}), " + f"got {branch.__name__}: a composite declaration of that " + f"kind is not expressible at column level." + ) + if branch is ColumnForeignKeyConstraint and not references: + raise ValueError( + "A ColumnForeignKeyConstraint needs references: the tuple " + "of (Table, [column names]) it points at." + ) + branch_kwargs: Dict[str, Any] = {"columns": list(self.columns)} + if branch is ColumnForeignKeyConstraint: + branch_kwargs.update( + references=references, + on_delete=on_delete, + on_update=on_update, + match_type=match_type, + ) + self.table_constraint = TableConstraint( + None, + branch=table_branch(None, **branch_kwargs), + name=name, + deferrable=deferrable is True, + not_deferrable=deferrable is False, + initially_deferred=initially_deferred is True, + initially_immediate=initially_deferred is False, + enforced=enforced is True, + not_enforced=enforced is False, + ) + self.constraint: Optional[ColumnConstraint] = None + return branch_fields = { "condition": condition, "references": references, @@ -474,7 +602,40 @@ def __init__( not_enforced=enforced is False, ) + @staticmethod + def _normalize_columns(columns: Optional[Sequence[str]]) -> Optional[Tuple[str, ...]]: + """Normalize the explicit ``columns`` list; validate it in isolation. + + The anchor position and model membership need the class, so they are + checked at class creation instead. + """ + if columns is None: + return None + names = tuple(columns) + if len(names) < 2: + raise TypeError( + "UseConstraint columns must list at least two column names; " + "a single-column constraint omits the parameter and anchors " + "on the annotated field alone." + ) + for column in names: + if not isinstance(column, str) or not column: + raise TypeError( + f"UseConstraint columns entries must be non-empty " + f"strings, got {column!r}." + ) + if len(set(names)) != len(names): + raise TypeError( + f"UseConstraint columns repeats a column: {names!r}." + ) + return names + def __repr__(self) -> str: + if self.table_constraint is not None: + return ( + f"UseConstraint({type(self.table_constraint.branch).__name__}, " + f"columns={self.columns!r})" + ) return f"UseConstraint({type(self.constraint.branch).__name__})" @@ -566,6 +727,35 @@ def __repr__(self) -> str: return f"UseGeneratedColumn({self.expression!r})" +class UsePartitionKey(DDLAnnotation): + """Marker for ``Annotated[T, UsePartitionKey()]``. + + Declares the annotated field as a member of the table's partition key, + in field-declaration order -- the declarative counterpart of the + direct-write ``__partition_key__`` tuple. UseColumn-aware: a renamed + field contributes its *column* name, because a partition key is a + column-level fact. + + Exactly one of the two faces may declare the key. A model that both + annotates fields and writes ``__partition_key__`` fails at class + creation with ``TypeError`` -- two sources for one fact is how the two + silently disagree. + + The first round accepts plain column tuples only. An expression + partition key (ClickHouse ``toYYYYMM(...)``, BigQuery ``DATE(...)``) + is rejected: the expression would have to be carried and rendered by + the DDL layer, which is a later piece of work. + + Example:: + + class JobLog(ActiveRecord): + @classmethod\n def primary_key(cls):\n return ("id", "bucket") + + id: int + bucket: Annotated[str, UsePartitionKey()] + """ + + class DerivedField: """ A marker/descriptor for declaring derived (computed) fields on ActiveRecord models. diff --git a/src/rhosocial/activerecord/cli/backends.py b/src/rhosocial/activerecord/cli/backends.py index afb03ea46..521a15e41 100644 --- a/src/rhosocial/activerecord/cli/backends.py +++ b/src/rhosocial/activerecord/cli/backends.py @@ -370,7 +370,7 @@ def resolve_connection(model, is_async: bool, args): if backend_reference: backend_class = load_backend_class(backend_reference, is_async) else: - backend_class = getattr(model, "__backend_class__", None) + backend_class = model.backend_class() if backend_class is None: raise CLIError( f"{model.__name__} has no backend class. Pass --backend-class " @@ -388,7 +388,7 @@ def resolve_connection(model, is_async: bool, args): elif config_reference: config = _build_config(args, load_config_class(config_reference)) else: - config = getattr(model, "__connection_config__", None) + config = model.connection_config() if config is None: raise CLIError( f"{model.__name__} has no connection config. Pass --config-class " diff --git a/src/rhosocial/activerecord/cli/errors.py b/src/rhosocial/activerecord/cli/errors.py index b86c00def..482278363 100644 --- a/src/rhosocial/activerecord/cli/errors.py +++ b/src/rhosocial/activerecord/cli/errors.py @@ -99,7 +99,7 @@ class ReadOnlyModelError(CLIError): def __init__(self, model_name: str, operation: str): super().__init__( - f"{model_name} is read-only (__read_only__ = True); " + f"{model_name} is read-only (read_only() returns True); " f"'{operation}' is not allowed.", exit_code=EXIT_READ_ONLY, ) diff --git a/src/rhosocial/activerecord/cli/interaction.py b/src/rhosocial/activerecord/cli/interaction.py index 648796e68..7973b2230 100644 --- a/src/rhosocial/activerecord/cli/interaction.py +++ b/src/rhosocial/activerecord/cli/interaction.py @@ -91,7 +91,10 @@ def soft_delete_action(model) -> str: def soft_delete_field(model) -> Optional[str]: """Return the declared soft-delete field name, if any.""" - return getattr(model, "__deleted_at_field__", None) + reader = getattr(model, "deleted_at_field", None) + if not callable(reader): + return None + return reader() def ensure_writable(model, operation: str) -> None: diff --git a/src/rhosocial/activerecord/cli/runtime.py b/src/rhosocial/activerecord/cli/runtime.py index 383377e3d..91cd5667d 100644 --- a/src/rhosocial/activerecord/cli/runtime.py +++ b/src/rhosocial/activerecord/cli/runtime.py @@ -8,6 +8,7 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple, Type from ..backend.expression import Column, ComparisonPredicate, Literal +from ..backend.expression.collect_columns import referenced_columns from ..query import ( ActiveQuery, AsyncActiveQuery, @@ -213,8 +214,15 @@ def base_query(model, args): def apply_filters(query, model, args): """Apply the simple ``--where`` / ``--order-by`` / ``--select`` options.""" - for predicate in parse_where(model, getattr(args, "where", None)): + referenced = set() + where_clauses = getattr(args, "where", None) + for predicate in parse_where(model, where_clauses): query = query.where(predicate) + referenced.update(referenced_columns(predicate)) + if where_clauses: + # The hint answers a ``--where`` that misses the partition columns; + # a bare unfiltered list makes no partition-scope claim to correct. + _hint_partition_scope(query, model, referenced, args) order_clauses = parse_order_by(model, getattr(args, "order_by", None)) if order_clauses: query = query.order_by(*order_clauses) @@ -224,6 +232,31 @@ def apply_filters(query, model, args): return query +def _hint_partition_scope(query, model, referenced, args) -> None: + """One stderr line when ``--where`` skips every partition column. + + The query-side warning fires on execution for the record; this is the + same difference said out loud at filter time, before the reader waits + on a scan they did not mean to ask for. Suppressed by ``--quiet`` like + every other advisory line. + """ + partitioned_by = getattr(model, "partition_key_columns", None) + if not callable(partitioned_by): + return + partition_columns = set(partitioned_by() or ()) + if not partition_columns or partition_columns & referenced: + return + if getattr(args, "quiet", False): + return + print( + f"{model.__name__} is partitioned by " + f"{', '.join(sorted(partition_columns))}; the given --where does not " + f"constrain any of them, so the query reads every partition. Add " + f"the column(s) if that is not the intent.", + file=sys.stderr, + ) + + def resolve_query_reference(reference: str, label: str = DEFAULT_QUERY_LABEL) -> Any: """Import and call a user query factory without arguments.""" target = import_symbol(reference, label) diff --git a/src/rhosocial/activerecord/field/composite_pk.py b/src/rhosocial/activerecord/field/composite_pk.py index 59e43ceb3..a4af7d246 100644 --- a/src/rhosocial/activerecord/field/composite_pk.py +++ b/src/rhosocial/activerecord/field/composite_pk.py @@ -1,5 +1,4 @@ # src/rhosocial/activerecord/field/composite_pk.py -from typing import ClassVar class CompositePKMixin: @@ -12,7 +11,10 @@ class CompositePKMixin: Usage: class OrderItem(CompositePKMixin, BaseActiveRecord): - __primary_key__ = ("order_id", "product_id") + @classmethod + def primary_key(cls): + return ("order_id", "product_id") + order_id: int product_id: int quantity: int @@ -24,7 +26,11 @@ class OrderItem(CompositePKMixin, BaseActiveRecord): composite key mixing a generated surrogate with a caller-supplied partition key is exactly the case it exists for. """ - __pk_auto_generated__: ClassVar[bool] = False + + @classmethod + def pk_auto_generated(cls) -> bool: + """A composite key is the application's to give -- override to change.""" + return False def __init__(self, **data): super().__init__(**data) diff --git a/src/rhosocial/activerecord/field/field_config.py b/src/rhosocial/activerecord/field/field_config.py index 1ac882c3a..cd98afd8d 100644 --- a/src/rhosocial/activerecord/field/field_config.py +++ b/src/rhosocial/activerecord/field/field_config.py @@ -39,7 +39,7 @@ class Order(ActiveRecord, SoftDeleteMixin): class FieldConfigValidationHandler: - """Runs every mixin-declared ``_validate_model_config`` on the new class. + """Runs the mixin-declared ``_validate_model_config`` on the new class. A mixin opts in by declaring this handler in ``_feature_handlers`` and exposing a ``_validate_model_config`` classmethod. The handler discovers @@ -47,11 +47,16 @@ class FieldConfigValidationHandler: ``__dict__`` — it never infers anything from attribute names, so adding a mixin is a one-line declaration rather than a naming convention to remember. + + Validators read the locator *accessors* (``deleted_at_field()`` and + friends), so an overriding answer is what gets validated -- which is + correct because D5 moved the declaration onto those methods, and an + accessor's answer is available the moment the class exists. """ @classmethod def handle(cls, model_class: Any) -> None: - """Validate the configuration of every field mixin on *model_class*.""" + """Validate field-mixin configs for *model_class*.""" for klass in model_class.__mro__: validator = klass.__dict__.get("_validate_model_config") if validator is not None: diff --git a/src/rhosocial/activerecord/field/read_only.py b/src/rhosocial/activerecord/field/read_only.py index 1d2e574f1..b2c56b5ff 100644 --- a/src/rhosocial/activerecord/field/read_only.py +++ b/src/rhosocial/activerecord/field/read_only.py @@ -29,20 +29,41 @@ class AuditLog(ActiveRecord, ReadOnlyMixin): The real guarantee is database credentials; this prevents accidents. """ -from typing import ClassVar from ..interface.update import IDeleteBehavior, IReadOnlyBehavior, IUpdateBehavior +from .field_config import FieldConfigValidationHandler class ReadOnlyMixin(IReadOnlyBehavior): """Read-only *semantics* -- declares no fields. - Inheriting this mixin sets ``__read_only__`` to True and makes the - framework refuse INSERT / UPDATE / DELETE issued through the model API. - Set ``__read_only__ = False`` explicitly to opt back out. + Inheriting this mixin makes the framework refuse INSERT / UPDATE / + DELETE issued through the model API. A model that is genuinely + writable overrides :meth:`read_only` to answer ``False``. """ - __read_only__: ClassVar[bool] = True + _feature_handlers = [FieldConfigValidationHandler] + + @classmethod + def read_only(cls) -> bool: + """Whether writes through framework paths must be refused -- declared + by overriding this method; inheriting this mixin answers ``True``. + + The single framework-wide decision point; every write gate reads this + value. Never queries the database -- read-only-ness is a static + declaration by the developer, not an introspection result, so a model + without a database connection still answers correctly. + + A classmethod rather than an instance method because the gates are not + all instance-level: ``bulk_create`` and friends are classmethods, and + ``ActiveQuery.update_all()`` / ``delete_all()`` only hold the model + *class*. Reading it off an instance would make the model-level + behaviour depend on where it is asked from. + + Returns: + bool: The declared read-only flag. + """ + return True def __init_subclass__(cls, **kwargs) -> None: super().__init_subclass__(**kwargs) @@ -84,26 +105,7 @@ def _validate_read_only_config(cls) -> None: raise TypeError( f"{cls.__name__} is read-only but also implements {', '.join(conflicts)}. " f"Those mixins register write event handlers that can never run, because " - f"every write is refused first. Remove read-only, or set " - f"__read_only__ = False if the model is genuinely writable." + f"every write is refused first. Remove read-only, or override " + f"read_only() to answer False if the model is genuinely " + f"writable." ) - - @classmethod - def read_only(cls) -> bool: - """Whether writes through framework paths must be refused. - - The single framework-wide decision point; every write gate reads this - value. Never queries the database -- read-only-ness is a static - declaration by the developer, not an introspection result, so a model - without a database connection still answers correctly. - - A classmethod rather than an instance method because the gates are not - all instance-level: ``bulk_create`` and friends are classmethods, and - ``ActiveQuery.update_all()`` / ``delete_all()`` only hold the model - *class*. Reading it off an instance would make the model-level - behaviour depend on where it is asked from. - - Returns: - bool: The declared read-only flag. - """ - return bool(getattr(cls, "__read_only__", False)) diff --git a/src/rhosocial/activerecord/field/soft_delete.py b/src/rhosocial/activerecord/field/soft_delete.py index 0a4b8cbfe..d99c056ed 100644 --- a/src/rhosocial/activerecord/field/soft_delete.py +++ b/src/rhosocial/activerecord/field/soft_delete.py @@ -16,7 +16,7 @@ """ from datetime import datetime, timezone -from typing import ClassVar, Dict, Any, Optional +from typing import Dict, Any, Optional from pydantic import Field @@ -32,18 +32,28 @@ class SoftDeleteMixin(IDeleteBehavior): """Soft-delete *semantics* — declares no field. The model (or a subclass like :class:`DefaultSoftDeleteMixin`) declares - a datetime field and announces it via ``__deleted_at_field__``; it must - exist or the class is rejected at definition time. + a datetime field and points at it by overriding + :meth:`deleted_at_field`; it must exist or the class is rejected at + definition time. """ - __deleted_at_field__: ClassVar[str] = "deleted_at" - _feature_handlers = [FieldConfigValidationHandler] def __init__(self, **data): super().__init__(**data) self.on(ModelEvent.BEFORE_DELETE, self._mark_as_deleted) + @classmethod + def deleted_at_field(cls) -> str: + """The soft-delete field -- declared by overriding this method. + + The default is the conventional ``deleted_at`` column. A model + whose soft-delete column has another name overrides the method; the + field-config validator then checks that overriding answer, so a + renamed column that does not exist fails at class definition. + """ + return "deleted_at" + @classmethod def _validate_model_config(cls) -> None: """Fail fast when the configured field name does not exist. @@ -52,10 +62,10 @@ def _validate_model_config(cls) -> None: so it runs once at class definition — see that module for why this cannot live in ``__init__``. """ - field_name = cls.__deleted_at_field__ + field_name = cls.deleted_at_field() if field_name not in cls.model_fields: raise TypeError( - f"{cls.__name__}: __deleted_at_field__ = {field_name!r} does " + f"{cls.__name__}: deleted_at_field() = {field_name!r} does " f"not name a model field. Declare the soft-delete field (e.g. " f"`{field_name}: Optional[datetime] = Field(default=None)`) or " f"use DefaultSoftDeleteMixin." @@ -64,15 +74,15 @@ def _validate_model_config(cls) -> None: @classmethod def _deleted_at_column(cls) -> str: """The database column name for the soft-delete field (UseColumn-aware).""" - return cls.get_column_name(cls.__deleted_at_field__) + return cls.get_column_name(cls.deleted_at_field()) def _mark_as_deleted(self, instance: "SoftDeleteMixin", **kwargs): """Mark record as soft deleted by setting the configured field.""" - setattr(instance, self.__deleted_at_field__, datetime.now(timezone.utc)) + setattr(instance, self.deleted_at_field(), datetime.now(timezone.utc)) def prepare_delete(self) -> Dict[str, Any]: """Prepare soft delete data (field name as key; mapped downstream).""" - field_name = self.__deleted_at_field__ + field_name = self.deleted_at_field() value = getattr(self, field_name) if value is None: raise ValueError( @@ -134,7 +144,7 @@ def _build_restore_condition(self): def restore(self) -> int: """Restore a soft-deleted record using expression system.""" - field_name = self.__deleted_at_field__ + field_name = self.deleted_at_field() if getattr(self, field_name) is None: return 0 @@ -166,7 +176,7 @@ class AsyncSoftDeleteMixin(SoftDeleteMixin): async def restore(self) -> int: """Restore a soft-deleted record asynchronously using expression system.""" - field_name = self.__deleted_at_field__ + field_name = self.deleted_at_field() if getattr(self, field_name) is None: return 0 diff --git a/src/rhosocial/activerecord/field/timestamp.py b/src/rhosocial/activerecord/field/timestamp.py index 12cc3c180..6f5af0989 100644 --- a/src/rhosocial/activerecord/field/timestamp.py +++ b/src/rhosocial/activerecord/field/timestamp.py @@ -17,7 +17,7 @@ """ from datetime import datetime, timezone -from typing import Any, ClassVar, Dict, Union +from typing import Any, Dict, Union from pydantic import Field, field_validator @@ -31,13 +31,12 @@ class TimestampMixin(IUpdateBehavior): """Timestamp *maintenance semantics* — declares no fields. The model (or a subclass like :class:`DefaultTimestampMixin`) declares - two datetime fields and announces them via ``__created_at_field__`` / - ``__updated_at_field__``; both must exist or instantiation fails fast. + two datetime fields; the defaults are the conventional ``created_at`` + and ``updated_at``, and a model with other names overrides + :meth:`created_at_field` / :meth:`updated_at_field`. An overriding + answer that names no field fails at class definition. """ - __created_at_field__: ClassVar[str] = "created_at" - __updated_at_field__: ClassVar[str] = "updated_at" - _feature_handlers = [FieldConfigValidationHandler] def __init__(self, **data): @@ -46,6 +45,16 @@ def __init__(self, **data): self.on(ModelEvent.BEFORE_INSERT, self._set_timestamps_on_insert) self.on(ModelEvent.BEFORE_UPDATE, self._set_updated_at) + @classmethod + def created_at_field(cls) -> str: + """The created-at field -- declared by overriding this method.""" + return "created_at" + + @classmethod + def updated_at_field(cls) -> str: + """The updated-at field -- declared by overriding this method.""" + return "updated_at" + @classmethod def _validate_model_config(cls) -> None: """Fail fast when the configured field names do not exist. @@ -54,8 +63,10 @@ def _validate_model_config(cls) -> None: so it runs once at class definition — see that module for why this cannot live in ``__init__``. """ - for knob in ("__created_at_field__", "__updated_at_field__"): - field_name = getattr(cls, knob) + for knob, field_name in ( + ("created_at_field()", cls.created_at_field()), + ("updated_at_field()", cls.updated_at_field()), + ): if field_name not in cls.model_fields: raise TypeError( f"{cls.__name__}: {knob} = {field_name!r} does not name a " @@ -72,8 +83,7 @@ def _set_timestamps_on_insert( Both timestamps are set to the same UTC value to ensure consistency. """ now = datetime.now(timezone.utc) - for knob in ("__created_at_field__", "__updated_at_field__"): - field_name = getattr(self, knob) + for field_name in (self.created_at_field(), self.updated_at_field()): setattr(instance, field_name, now) if data is not None: data[field_name] = now @@ -83,7 +93,7 @@ def _set_updated_at( ) -> None: """Set the configured updated-at field for UPDATE operations.""" now = datetime.now(timezone.utc) - field_name = self.__updated_at_field__ + field_name = self.updated_at_field() setattr(instance, field_name, now) if data is not None: data[field_name] = now diff --git a/src/rhosocial/activerecord/field/uuid.py b/src/rhosocial/activerecord/field/uuid.py index 1dcef3856..b39c1123f 100644 --- a/src/rhosocial/activerecord/field/uuid.py +++ b/src/rhosocial/activerecord/field/uuid.py @@ -1,6 +1,7 @@ # src/rhosocial/activerecord/field/uuid.py """Module providing UUID functionality.""" +import sys import uuid from typing import Any, Dict from pydantic import Field @@ -28,10 +29,52 @@ class UUIDMixin(IDataPreparationBehavior): id: uuid.UUID = Field(default_factory=uuid.uuid4) + @classmethod + def uuid_version(cls) -> str: + """Which UUID variant new keys use -- ``"uuid4"`` unless overridden. + + Declared by overriding this method. ``"uuid7"`` selects the + time-ordered variant from RFC 9562, whose leading timestamp makes + index inserts append rather than scatter: the right choice for a + key that is also the physical ordering of a B-tree. ``uuid4`` is + the default because it needs no runtime support beyond Python + itself. + """ + return "uuid4" + + @classmethod + def _generate_uuid(cls) -> uuid.UUID: + """A new primary key, in the variant :meth:`uuid_version` selects. + + ``uuid7`` is probed for rather than imported: the stdlib gained it + in Python 3.14, and this package supports 3.8 onwards, so a model + selecting it on an older interpreter gets a message saying what is + missing instead of an ``ImportError`` from a module import. + """ + version = cls.uuid_version() + if version == "uuid4": + return uuid.uuid4() + if version == "uuid7": + generator = getattr(uuid, "uuid7", None) + if generator is None: + raise RuntimeError( + f"{cls.__name__} selects uuid7(), which this interpreter " + f"does not provide: the standard library gained it in " + f"Python 3.14 (RFC 9562), and the running version is " + f"{'.'.join(str(part) for part in sys.version_info[:3])}. " + f"Declare uuid_version() as 'uuid4' on this interpreter, " + f"or upgrade it." + ) + return generator() + raise ValueError( + f"{cls.__name__}.uuid_version() returned {version!r}; the supported " + f"variants are 'uuid4' and 'uuid7'." + ) + def __init__(self, **data): pk_field = self.primary_key() if pk_field not in data: - data[pk_field] = uuid.uuid4() + data[pk_field] = self._generate_uuid() super().__init__(**data) def prepare_save_data(self, data: Dict[str, Any], is_new: bool) -> Dict[str, Any]: @@ -47,7 +90,7 @@ def prepare_save_data(self, data: Dict[str, Any], is_new: bool) -> Dict[str, Any pk_field = self.primary_key() if is_new and not getattr(self, pk_field, None): - uuid_value = uuid.uuid4() + uuid_value = self._generate_uuid() setattr(self, pk_field, uuid_value) data[pk_field] = uuid_value elif pk_field not in data and getattr(self, pk_field, None): diff --git a/src/rhosocial/activerecord/field/version.py b/src/rhosocial/activerecord/field/version.py index 0cded6245..de07570bf 100644 --- a/src/rhosocial/activerecord/field/version.py +++ b/src/rhosocial/activerecord/field/version.py @@ -51,7 +51,7 @@ class Article(OptimisticLockMixin, ActiveRecord): from ..backend.errors import DatabaseError, StaleRecordError from ..base.fields import UseConstraint from pydantic import Field -from typing import Any, ClassVar, Dict, List, Union +from typing import Any, Dict, List, Union if sys.version_info >= (3, 9): @@ -63,9 +63,9 @@ class Article(OptimisticLockMixin, ActiveRecord): class OptimisticLockMixin(IUpdateBehavior): """Optimistic-lock *semantics* — declares no field. - Subclasses (or the model) must declare an integer field and announce - it via ``__version_field__``; the column name follows the standard - resolution (``UseColumn`` on the declared field). The mixin + Subclasses (or the model) must declare an integer field and point at it + by overriding :meth:`version_field`; the column name follows the + standard resolution (``UseColumn`` on the declared field). The mixin contributes the lock semantics on UPDATE: * WHERE `` == `` @@ -76,11 +76,18 @@ class OptimisticLockMixin(IUpdateBehavior): :class:`StaleRecordError` is raised. """ - __version_field__: ClassVar[str] = "version" - __version_increment_by__: ClassVar[int] = 1 - _feature_handlers = [FieldConfigValidationHandler] + @classmethod + def version_field(cls) -> str: + """The version field -- declared by overriding this method.""" + return "version" + + @classmethod + def version_increment_by(cls) -> int: + """How far the version steps per UPDATE -- override to change it.""" + return 1 + # Framework bookkeeping — deliberately private and invisible: the last # version value known to be committed to the database. It must never # track in-memory user assignments, otherwise the lock condition could @@ -106,46 +113,58 @@ def __init__(self, **data): # ------------------------------------------------------------------ @classmethod def _validate_model_config(cls) -> None: - """Fail fast when ``__version_field__`` does not name a real field. + """Fail fast when ``version_field()`` does not name a real field. Invoked by :class:`FieldConfigValidationHandler` from the metaclass, so it runs once at class definition — see that module for why this cannot live in ``__init__``. """ - field_name = cls.__version_field__ + field_name = cls.version_field() if field_name not in cls.model_fields: raise TypeError( - f"{cls.__name__}: __version_field__ = {field_name!r} does not " + f"{cls.__name__}: version_field() = {field_name!r} does not " f"name a model field. Declare the version field (e.g. " f"`{field_name}: int = Field(default=1, ge=1)`) or use " f"DefaultOptimisticLockMixin." ) - if cls.__version_increment_by__ <= 0: + increment = cls.version_increment_by() + if increment is None or increment <= 0: raise ValueError( - f"{cls.__name__}: __version_increment_by__ must be positive, " - f"got {cls.__version_increment_by__!r}" + f"{cls.__name__}: version_increment_by() must be positive, " + f"got {increment!r}" ) @classmethod def _version_field_name(cls) -> str: - """The Python field name carrying the version (``__version_field__``).""" - return cls.__version_field__ + """The Python field name carrying the version.""" + return cls.version_field() @classmethod def _version_column_name(cls) -> str: """The database column name for the version field (UseColumn-aware).""" - return cls.get_column_name(cls.__version_field__) + return cls.get_column_name(cls.version_field()) @classmethod def _version_increment(cls) -> int: """The configured increment applied on each UPDATE.""" - return cls.__version_increment_by__ + return cls.version_increment_by() @property def version_value(self) -> int: """Current in-memory version value (of the configured field).""" return getattr(self, self._version_field_name()) + def refresh_lock_state(self) -> None: + """Re-read the committed version after an instance-level re-read. + + Called by ``reload()``: the instance has just been overwritten with + the stored row, so the value it holds *is* the committed one, and + the snapshot must follow it -- otherwise the next UPDATE would + compare against the pre-reload version and report a stale record + that is not stale. + """ + self._version_snapshot = self.version_value + # ------------------------------------------------------------------ # Lock semantics (IUpdateBehavior) # ------------------------------------------------------------------ diff --git a/src/rhosocial/activerecord/interface/model.py b/src/rhosocial/activerecord/interface/model.py index e1727ae6d..1af6e00af 100644 --- a/src/rhosocial/activerecord/interface/model.py +++ b/src/rhosocial/activerecord/interface/model.py @@ -33,16 +33,15 @@ class ActiveRecordBase(BaseModel, ABC): __backend_class__ (Type[StorageBackend]): Backend implementation class __connection_config__ (ConnectionConfig): Connection configuration __logger__ (Logger): Logger instance - __column_types_cache__ (Dict[str, Any]): Column type cache _dirty_fields (Set[str]): Set of modified field names __no_track_fields__ (Set[str]): Fields excluded from change tracking _original_values (Dict): Original field values before modification """ - __table_name__: ClassVar[Optional[str]] = None - __schema_name__: ClassVar[Optional[str]] = None - __primary_key__: ClassVar[PrimaryKeyDef] = "id" - __pk_auto_generated__: ClassVar[bool] = True + # Partition key column names, declared directly or extracted from + # UsePartitionKey annotations at class creation. Empty for a + # non-partitioned model. + __partition_key__: ClassVar[Tuple[str, ...]] = () __backend__: Optional[Union[StorageBackend, AsyncStorageBackend]] = None __backend_class__: ClassVar[Type[Union[StorageBackend, AsyncStorageBackend]]] = None __connection_config__: ClassVar[Optional[ConnectionConfig]] = None @@ -59,24 +58,36 @@ def __init__(self, **data): self._event_handlers = {event: [] for event in ModelEvent} def __init_subclass__(cls) -> None: - """Initialize subclass by merging all non-tracking fields.""" + """Initialize subclass by resetting per-class framework state.""" super().__init_subclass__() - # Collect non-tracking fields from all parent classes - no_track_fields = set() - for base in cls.__mro__: - if hasattr(base, "__no_track_fields__"): - no_track_fields.update(base.__no_track_fields__) - cls.__no_track_fields__ = no_track_fields - cls.__column_types_cache__ = None # Initialize _dummy_backend to None for each subclass cls._dummy_backend = None + @classmethod + def no_track_fields(cls) -> Set[str]: + """Fields excluded from change tracking, as the MRO union. + + The accessor form of ``__no_track_fields__``: the mixins that + declare fields nobody should track each declare their own set, + and a model's answer is the union over its MRO -- so a mixin + added anywhere in the inheritance chain contributes, and a + model never has to re-declare what its mixins already declared. + """ + collected: Set[str] = set() + for base in cls.__mro__: + declared = base.__dict__.get("__no_track_fields__") + if declared: + collected.update(declared) + return collected + @classmethod def table_name(cls) -> str: - """Get the table name for this model. + """The table this model maps -- **declared by overriding this method**. - Returns the class's __table_name__ attribute by default. Subclasses can override - for dynamic table names. + There is no default: a model with no ``table_name()`` override is a + declaration mistake, since nothing in the framework can guess which + table a model means, and the failure surfaces here, at the first + read, rather than as ``None`` somewhere deep in a statement. Qualifier binding: Column expressions built through ``Model.c.field`` capture this @@ -94,25 +105,19 @@ def table_name(cls) -> str: @classmethod def table_name(cls): # Monthly partitioned table - return f"{cls.__table_name__}_{datetime.now():%Y%m}" - - @classmethod - def table_name(cls): - # Add table prefix - return f"{cls.get_prefix()}{cls.__table_name__}" + return f"logs_{datetime.now():%Y%m}" """ - if cls.__table_name__ is None: - raise ValueError(f"table_name not set for {cls.__name__}") - if type(cls.__table_name__) is not str: - raise ValueError(f"table_name must be str, not {cls.__table_name__}") - return cls.__table_name__ + raise NotImplementedError( + f"{cls.__name__} does not declare a table name. Override " + "table_name() on the model class and return the table name." + ) @classmethod def schema_name(cls) -> Optional[str]: - """Get the schema name for this model. + """The schema this model maps, or ``None`` to resolve through the connection. - Returns the class's __schema_name__ attribute by default. Subclasses can override - for dynamic schema names. + Declared by overriding this method; the default is no schema, which + lets the backend resolve through its default schema / search_path. Schema namespaces: On databases that support schemas (PostgreSQL, SQL Server, ...), @@ -130,23 +135,8 @@ def schema_name(cls) -> Optional[str]: Returns: Optional[str]: Schema name, or None if not set - - Example: - @classmethod - def schema_name(cls): - # Dynamic schema based on tenant - return f"tenant_{cls.get_tenant_id()}" - - Cross-schema join (each side keeps its own qualifiers): - - Order.query().join( - Customer, - on=Order.c.customer_id == Customer.c.id, - ).select(Order.c.id, Customer.c.name) - # PostgreSQL: FROM "shop"."orders" - # JOIN "crm"."customers" ON ... """ - return cls.__schema_name__ + return None @classmethod def primary_key(cls) -> PrimaryKeyDef: @@ -159,9 +149,10 @@ def primary_key(cls) -> PrimaryKeyDef: Example: @classmethod def primary_key(cls): - return f"{cls.__primary_key__}_{cls.get_shard()}" + # Sharded key: the shard is chosen per tenant + return f"{cls.__name__.lower()}_id" """ - return cls.__primary_key__ + return "id" @classmethod def addressable(cls) -> bool: @@ -182,7 +173,7 @@ def addressable(cls) -> bool: Orthogonal to read-only-ness: a keyless table may be writable, and a read-only table may well have an auto-generated key. """ - return cls.__primary_key__ is not None + return cls.primary_key() is not None @classmethod def refuse_read_only(cls, operation: str) -> None: @@ -213,7 +204,7 @@ def refuse_read_only(cls, operation: str) -> None: @classmethod def is_composite_pk(cls) -> bool: """Check if the model uses a composite primary key.""" - return isinstance(cls.__primary_key__, tuple) + return isinstance(cls.primary_key(), tuple) @classmethod def primary_key_columns(cls) -> Tuple[str, ...]: @@ -222,11 +213,44 @@ def primary_key_columns(cls) -> Tuple[str, ...]: Empty for a keyless model (``__primary_key__ = None``). Never contains ``None``: callers can iterate the result unconditionally. """ - pk = cls.__primary_key__ + pk = cls.primary_key() if pk is None: return () return pk if isinstance(pk, tuple) else (pk,) + @classmethod + def partition_key_columns(cls) -> Tuple[str, ...]: + """Partition key column names, as a tuple. + + Empty for a non-partitioned model. Mirrors + :meth:`primary_key_columns` in spelling -- column names, because a + WHERE tree carries columns, and the query layer compares the two + to decide whether a lookup is partition-scoped. + + The value comes from ``__partition_key__``, which is either + extracted from ``UsePartitionKey`` field annotations at class + creation or declared directly (an introspection-generated model + takes the direct path). + """ + return tuple(getattr(cls, "__partition_key__", ()) or ()) + + @classmethod + def natural_key(cls) -> Tuple[str, ...]: + """Fields that identify a row when the primary key cannot, or ``()``. + + Declared by overriding this method. The natural key is the + application-level identity -- the columns an upsert or a "find or + create" searches on, like a business code or (`tenant`, + `external_id`). Empty by default: a model that never declares one + keeps the caller-supplied lookup it had before, so declaring this + is a pure addition. + + Field names, not column names, because the value is consumed where + model fields are addressed (``find_one_or_create``); the column + spelling comes out of :meth:`column_name` at that point. + """ + return () + @classmethod def get_generated_columns(cls) -> Tuple[str, ...]: """Column names backed by generated (computed) expressions. @@ -351,7 +375,7 @@ def _insert_pk_exclusions(cls) -> Set[str]: identity = set(cls.identity_pk_fields()) if identity: return identity - if not cls.__pk_auto_generated__: + if not cls.pk_auto_generated(): return set() return set(cls.primary_key_fields()) & set(cls.model_fields) @@ -377,10 +401,49 @@ def required_pk_fields(cls) -> Tuple[str, ...]: identity = set(cls.identity_pk_fields()) if identity: return tuple(f for f in cls.primary_key_fields() if f not in identity) - if not cls.__pk_auto_generated__: + if not cls.pk_auto_generated(): return cls.primary_key_fields() return () + @classmethod + def pk_auto_generated(cls) -> bool: + """Whether the primary key is database-generated. + + Declared by overriding this method. The default is ``True``: the + conventional single-column ``id`` is the database's to supply, + which is what the insert paths assume. A model whose key the + application writes overrides it to ``False``. + """ + return True + + @classmethod + def backend_class(cls) -> Optional[Type[Union[StorageBackend, AsyncStorageBackend]]]: + """The backend implementation class this model is configured with. + + The accessor form of ``__backend_class__``: ``configure()`` writes + the landing spot, consumers read here. ``None`` before configuration. + """ + return cls.__backend_class__ + + @classmethod + def connection_config(cls) -> Optional[ConnectionConfig]: + """The connection configuration this model is configured with. + + The accessor form of ``__connection_config__``: ``configure()`` + resolves callable configurations before storing, so this reads the + instance it will connect with. ``None`` before configuration. + """ + return cls.__connection_config__ + + @classmethod + def logging_config(cls) -> Optional[Any]: + """The logging configuration this model is configured with. + + The accessor form of ``__logging_config__``: ``configure()`` writes + the landing spot, consumers read here. ``None`` before configuration. + """ + return cls.__logging_config__ + @classmethod def backend(cls) -> Union[StorageBackend, AsyncStorageBackend]: """Get storage backend instance. @@ -575,7 +638,7 @@ def __setattr__(self, name: str, value: Any): should_track = ( name in self.__class__.model_fields and hasattr(self, "_original_values") - and name not in self.__class__.__no_track_fields__ + and name not in self.__class__.no_track_fields() ) if should_track and name not in self._original_values: self._original_values[name] = getattr(self, name, None) @@ -627,7 +690,6 @@ class IActiveRecord(ActiveRecordBase): __backend_class__ (Type[StorageBackend]): Backend implementation class __connection_config__ (ConnectionConfig): Connection configuration __logger__ (Logger): Logger instance - __column_types_cache__ (Dict[str, Any]): Column type cache _dirty_fields (Set[str]): Set of modified field names __no_track_fields__ (Set[str]): Fields excluded from change tracking _original_values (Dict): Original field values before modification @@ -860,7 +922,6 @@ class IAsyncActiveRecord(ActiveRecordBase): __backend_class__ (Type[AsyncStorageBackend]): Backend implementation class __connection_config__ (ConnectionConfig): Connection configuration __logger__ (Logger): Logger instance - __column_types_cache__ (Dict[str, Any]): Column type cache _dirty_fields (Set[str]): Set of modified field names __no_track_fields__ (Set[str]): Fields excluded from change tracking _original_values (Dict): Original field values before modification diff --git a/src/rhosocial/activerecord/logging/mixin.py b/src/rhosocial/activerecord/logging/mixin.py index 5ff9ca5bf..8156acc3e 100644 --- a/src/rhosocial/activerecord/logging/mixin.py +++ b/src/rhosocial/activerecord/logging/mixin.py @@ -79,6 +79,16 @@ def get_logger(cls) -> logging.Logger: logger_name = cls._get_logger_name() return cls.get_logging_config().get_logger(logger_name) + @classmethod + def logger_name(cls) -> Optional[str]: + """The custom logger name this model declares, or ``None``. + + The accessor form of ``__logger_name__``: consumers read here, so + a subclass that derives its logger name dynamically overrides the + method instead of maintaining an attribute in step with it. + """ + return cls.__logger_name__ + @classmethod def _get_logger_name(cls) -> str: """Determine the appropriate logger name for this class. @@ -96,8 +106,9 @@ def _get_logger_name(cls) -> str: str: The logger name. """ # If custom logger name is set, use it - if cls.__logger_name__: - return cls.__logger_name__ + custom_name = cls.logger_name() + if custom_name: + return custom_name # Check if this is a library class or user-defined class module = cls.__module__ diff --git a/src/rhosocial/activerecord/query/active_query.py b/src/rhosocial/activerecord/query/active_query.py index d889d8187..e17ed7add 100644 --- a/src/rhosocial/activerecord/query/active_query.py +++ b/src/rhosocial/activerecord/query/active_query.py @@ -50,25 +50,48 @@ def _warn_partial_key_scope(query: "IQuery") -> None: thinks they wrote a row address is mistaken, and they are the one who needs the message. So the message names the table, names the columns, and says what the query will match. + + Two independent checks share one tree walk. The primary-key check fires + when a keyed model's conditions leave part of the key unconstrained. The + partition-key check fires when a partitioned model's conditions mention + none of the partition columns at all -- and it must not be gated on the + primary-key answer, because a partition key the primary key does not + contain (or a keyless partitioned log) is exactly the case where the + early return would swallow the warning. """ model = query.model_class + referenced = None wanted = set(model.required_pk_columns()) - if not wanted: + if wanted: # No key column is the caller's to give, so no query can address a # row more completely than this one already does. Checking that first # also keeps the tree walk off the path of the ordinary single-key # model, which is every query this service runs. + referenced = referenced_columns(query.where_clause) + uncovered = wanted - referenced + if uncovered: + query._log( + logging.WARNING, + "query on %s leaves key column(s) %s unconstrained: it matches " + "every value those columns can hold, so it is not a row " + "lookup -- and on a table partitioned by one of them it scans " + "every partition. Add the column(s) to the query if that is " + "not the intent." + % (model.table_name(), ", ".join(uncovered)), + ) + partitioned_by = set(model.partition_key_columns()) + if not partitioned_by: return - uncovered = wanted - referenced_columns(query.where_clause) - if not uncovered: + if referenced is None: + referenced = referenced_columns(query.where_clause) + if partitioned_by & referenced: return query._log( logging.WARNING, - "query on %s leaves key column(s) %s unconstrained: it matches every " - "value those columns can hold, so it is not a row lookup -- and on a " - "table partitioned by one of them it scans every partition. Add the " - "column(s) to the query if that is not the intent." - % (model.table_name(), ", ".join(uncovered)), + "query on %s constrains none of its partition key column(s) %s: it " + "reads every partition instead of the one(s) those columns select. " + "Add the column(s) to the query if that is not the intent." + % (model.table_name(), ", ".join(sorted(partitioned_by))), ) diff --git a/tests/providers/basic_connection.py b/tests/providers/basic_connection.py index a409720a0..281ae4aca 100644 --- a/tests/providers/basic_connection.py +++ b/tests/providers/basic_connection.py @@ -25,7 +25,9 @@ def _create_test_model_class(base_class, class_name: str): # Create a new class that inherits from the base class TestModel(base_class): - __table_name__ = "test_users" + @classmethod + def table_name(cls): + return "test_users" TestModel.__name__ = class_name return TestModel @@ -34,7 +36,10 @@ class TestModel(base_class): class SyncTestUser(ActiveRecord): """Sync test user model for connection pool tests.""" - __table_name__ = "test_users" + @classmethod + def table_name(cls): + return "test_users" + id: Optional[int] = None name: str email: str @@ -43,7 +48,10 @@ class SyncTestUser(ActiveRecord): class AsyncTestUser(AsyncActiveRecord): """Async test user model for connection pool tests.""" - __table_name__ = "test_users" + @classmethod + def table_name(cls): + return "test_users" + id: Optional[int] = None name: str email: str diff --git a/tests/providers/query_connection.py b/tests/providers/query_connection.py index 2fed4daba..6fbdb0655 100644 --- a/tests/providers/query_connection.py +++ b/tests/providers/query_connection.py @@ -23,7 +23,10 @@ class SyncQueryTestUser(ActiveRecord): """Sync test user model for query connection pool tests.""" - __table_name__ = "test_users" + @classmethod + def table_name(cls): + return "test_users" + id: Optional[int] = None name: str email: str @@ -32,7 +35,10 @@ class SyncQueryTestUser(ActiveRecord): class AsyncQueryTestUser(AsyncActiveRecord): """Async test user model for query connection pool tests.""" - __table_name__ = "test_users" + @classmethod + def table_name(cls): + return "test_users" + id: Optional[int] = None name: str email: str diff --git a/tests/rhosocial/activerecord_test/feature/backend/named_connection/test_callable_config.py b/tests/rhosocial/activerecord_test/feature/backend/named_connection/test_callable_config.py index f35b7b318..b9b9b4fc1 100644 --- a/tests/rhosocial/activerecord_test/feature/backend/named_connection/test_callable_config.py +++ b/tests/rhosocial/activerecord_test/feature/backend/named_connection/test_callable_config.py @@ -30,7 +30,9 @@ class CallableUser(IntegerPKMixin, ActiveRecord): """Test User model for callable config tests.""" - __table_name__ = "callable_users" + @classmethod + def table_name(cls): + return "callable_users" id: Optional[int] = None name: str @@ -39,7 +41,9 @@ class CallableUser(IntegerPKMixin, ActiveRecord): class AsyncCallableUser(IntegerPKMixin, AsyncActiveRecord): """Test async User model for callable config tests.""" - __table_name__ = "callable_users" + @classmethod + def table_name(cls): + return "callable_users" id: Optional[int] = None name: str diff --git a/tests/rhosocial/activerecord_test/feature/basic/connection/test_declaration_accessors.py b/tests/rhosocial/activerecord_test/feature/basic/connection/test_declaration_accessors.py new file mode 100644 index 000000000..0f3edf10b --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/basic/connection/test_declaration_accessors.py @@ -0,0 +1,173 @@ +# tests/rhosocial/activerecord_test/feature/basic/connection/test_declaration_accessors.py +"""Declaration accessors: consumers read the accessor, not the dunder. + +D3a: framework code that used to read a dunder directly now calls the +accessor, so a model that overrides the method is obeyed. Each rewritten +read site has a before/after test here -- the default behaviour is +unchanged (the accessor returns exactly what the dunder held), and an +override is now followed by the dependent answers. +""" +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend +from rhosocial.activerecord.backend.impl.sqlite.backend.async_backend import ( + AsyncSQLiteBackend, +) +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.model import AsyncActiveRecord, ActiveRecord + +import pytest + + +class ApplicationKeyUser(ActiveRecord): + @classmethod + def table_name(cls): + return "accessor_application_key_users" + + @classmethod + def primary_key(cls): + return ("code", "bucket") + + @classmethod + def pk_auto_generated(cls): + return False + + code: str + bucket: str + + +class SurrogateKeyUser(ActiveRecord): + @classmethod + def table_name(cls): + return "accessor_surrogate_key_users" + + id: int + code: str + + +class OverriddenAutoKeyUser(SurrogateKeyUser): + """Same dunder as its parent, but the method answers differently.""" + + @classmethod + def pk_auto_generated(cls) -> bool: + return False + + +class TestPkAutoGeneratedAccessor: + def test_declared_flag_is_returned(self): + assert SurrogateKeyUser.pk_auto_generated() is True + assert ApplicationKeyUser.pk_auto_generated() is False + + def test_override_changes_the_dependent_answers(self): + # The point of the accessor: every key question that used to read + # the dunder now reads the method, so an override is obeyed across + # the board instead of being bypassed by some readers. + assert OverriddenAutoKeyUser.pk_auto_generated() is False + assert OverriddenAutoKeyUser.required_pk_fields() == ("id",) + assert OverriddenAutoKeyUser._insert_pk_exclusions() == set() + # ...and the un-overridden parent still answers from its declaration. + assert SurrogateKeyUser.pk_auto_generated() is True + assert SurrogateKeyUser.required_pk_fields() == () + + def test_override_is_inherited_by_subclasses(self): + class SubclassOf(OverriddenAutoKeyUser): + @classmethod + def table_name(cls): + return "accessor_subclass_of_overridden" + + assert SubclassOf.pk_auto_generated() is False + + +class TestConnectionAccessors: + def test_before_configuration_they_are_none(self): + class Unconfigured(ActiveRecord): + @classmethod + def table_name(cls): + return "accessors_unconfigured" + + id: int + + assert Unconfigured.backend_class() is None + assert Unconfigured.connection_config() is None + # Logging is the exception: the framework installs a default config + # at import, so the accessor reports that default, not None. + assert Unconfigured.logging_config() is not None + + def test_configure_writes_what_the_accessors_read(self, tmp_path): + config = SQLiteConnectionConfig(database=str(tmp_path / "accessors.db")) + + class Configured(ActiveRecord): + @classmethod + def table_name(cls): + return "accessors_configured" + + id: int + + Configured.configure(config, SQLiteBackend) + assert Configured.backend_class() is SQLiteBackend + assert Configured.connection_config() is config + + @pytest.mark.asyncio + async def test_async_configure_writes_what_the_accessors_read(self, tmp_path): + config = SQLiteConnectionConfig(database=str(tmp_path / "accessors_async.db")) + + class AsyncConfigured(AsyncActiveRecord): + @classmethod + def table_name(cls): + return "accessors_configured_async" + + id: int + + await AsyncConfigured.configure(config, AsyncSQLiteBackend) + assert AsyncConfigured.backend_class() is AsyncSQLiteBackend + assert AsyncConfigured.connection_config() is config + + +class TestFeatureLocatorAccessors: + """The P0 appendix items: logger name and no-track fields as accessors.""" + + def test_logger_name_defaults_to_none_then_is_overridable(self): + class Named(ActiveRecord): + @classmethod + def table_name(cls): + return "accessors_named" + + __logger_name__ = "myapp.named" + + id: int + + class Unnamed(ActiveRecord): + @classmethod + def table_name(cls): + return "accessors_unnamed" + + id: int + + assert Named.logger_name() == "myapp.named" + assert Unnamed.logger_name() is None + # The consumer obeys the override instead of reading the flag. + assert Named._get_logger_name() == "myapp.named" + + def test_no_track_fields_is_the_mro_union(self): + class TrackingMixin(ActiveRecord): + __no_track_fields__ = {"from_mixin"} + + id: int + + class Joined(TrackingMixin): + @classmethod + def table_name(cls): + return "accessors_joined" + + __no_track_fields__ = {"from_model"} + + class Inheritor(Joined): + @classmethod + def table_name(cls): + return "accessors_inheritor" + + assert Joined.no_track_fields() == {"from_mixin", "from_model"} + # A subclass that declares nothing still inherits the mixin's share: + # the union climbs, so a mixin added higher in the MRO lands here. + assert "from_mixin" in Inheritor.no_track_fields() + # The snapshot attribute is gone: the accessor is the only reader, + # so a flat copy cannot go stale against a late mixin. + assert "no_track_fields" not in Joined.__dict__ diff --git a/tests/rhosocial/activerecord_test/feature/basic/crud/test_reload_stale_recipe.py b/tests/rhosocial/activerecord_test/feature/basic/crud/test_reload_stale_recipe.py new file mode 100644 index 000000000..d40b38516 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/basic/crud/test_reload_stale_recipe.py @@ -0,0 +1,175 @@ +# tests/rhosocial/activerecord_test/feature/basic/crud/test_reload_stale_recipe.py +"""``reload()``: the second half of the stale-record recipe. + +A refused write means the server's row moved on. ``reload()`` takes those +values back into the same instance and re-points the optimistic lock at +them, so a retry is a retry of the change rather than a second conflict. +""" +from typing import ClassVar, Optional + +from rhosocial.activerecord.backend.errors import RecordNotFound +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.base import FieldProxy +from rhosocial.activerecord.field import OptimisticLockMixin +from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord + +import pytest + + +class LockedCounter(OptimisticLockMixin, ActiveRecord): + @classmethod + def table_name(cls): + return "reload_counters" + + c: ClassVar[FieldProxy] = FieldProxy() + + id: Optional[int] = None + name: str = "" + value: int = 0 + version: int = 1 + + +class KeylessNote(ActiveRecord): + @classmethod + def table_name(cls): + return "reload_keyless" + + @classmethod + def primary_key(cls): + return None + + c: ClassVar[FieldProxy] = FieldProxy() + + body: str = "" + + +DDL = [ + 'CREATE TABLE "reload_counters" (' + '"id" INTEGER PRIMARY KEY AUTOINCREMENT, ' + '"name" TEXT NOT NULL DEFAULT \'\', ' + '"value" INTEGER NOT NULL DEFAULT 0, ' + '"version" INTEGER NOT NULL DEFAULT 1)', +] + + +@pytest.fixture +def counters(tmp_path): + LockedCounter.configure( + SQLiteConnectionConfig(database=str(tmp_path / "reload.db")), SQLiteBackend + ) + backend = LockedCounter.backend() + for statement in DDL: + backend.execute(statement) + return LockedCounter + + +class TestReload: + def test_reload_takes_the_stored_values(self, counters): + record = counters(name="c", value=1) + record.save() + + # Another writer moves the row on. + other = counters.find_one(record.id) + other.value = 7 + other.save() + + record.value = 3 + record.reload() + assert record.value == 7 + + def test_reload_resets_change_tracking(self, counters): + record = counters(name="c", value=1) + record.save() + record.value = 99 + assert record.is_dirty + + record.reload() + assert not record.is_dirty + assert record.value == 1 + + def test_reload_refreshes_the_lock_snapshot(self, counters): + record = counters(name="c", value=1) + record.save() + + other = counters.find_one(record.id) + other.value = 5 + other.save() + + # Without the refresh the next save would compare against the + # pre-reload version and report a stale record that is not stale. + record.reload() + record.value = 6 + record.save() + stored = counters.find_one(record.id) + assert stored.value == 6 + + def test_reload_without_a_key_value_is_refused(self, counters): + with pytest.raises(ValueError, match="primary key"): + counters(name="c").reload() + + def test_reload_of_a_deleted_row_raises(self, counters): + record = counters(name="c", value=1) + record.save() + counters.find_one(record.id).delete() + with pytest.raises(RecordNotFound): + record.reload() + + def test_reload_on_a_keyless_model_is_unaddressable(self, tmp_path): + from rhosocial.activerecord.backend.errors import UnaddressableRecordError + + KeylessNote.configure( + SQLiteConnectionConfig(database=str(tmp_path / "keyless.db")), SQLiteBackend + ) + KeylessNote.backend().execute( + 'CREATE TABLE "reload_keyless" ("body" TEXT NOT NULL DEFAULT \'\')' + ) + with pytest.raises(UnaddressableRecordError, match="reload"): + KeylessNote(body="x").reload() + + +class TestAsyncReload: + @pytest.fixture + async def async_counters(self, async_user_class): + backend = async_user_class.backend() + AsyncLockedCounter.__backend__ = backend + await backend.executescript("; ".join(DDL)) + yield AsyncLockedCounter + + @pytest.mark.asyncio + async def test_async_reload_takes_the_stored_values(self, async_counters): + record = async_counters(name="c", value=1) + await record.save() + + other = await async_counters.find_one(record.id) + other.value = 7 + await other.save() + + record.value = 3 + await record.reload() + assert record.value == 7 + + @pytest.mark.asyncio + async def test_async_reload_resets_change_tracking(self, async_counters): + record = async_counters(name="c", value=1) + await record.save() + record.value = 99 + assert record.is_dirty + + await record.reload() + assert not record.is_dirty + assert record.value == 1 + + + +class AsyncLockedCounter(OptimisticLockMixin, AsyncActiveRecord): + @classmethod + def table_name(cls): + return "reload_counters" + + c: ClassVar[FieldProxy] = FieldProxy() + + id: Optional[int] = None + name: str = "" + value: int = 0 + version: int = 1 diff --git a/tests/rhosocial/activerecord_test/feature/basic/ddl/test_ddl_composite_annotations.py b/tests/rhosocial/activerecord_test/feature/basic/ddl/test_ddl_composite_annotations.py new file mode 100644 index 000000000..86b5c7d4b --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/basic/ddl/test_ddl_composite_annotations.py @@ -0,0 +1,362 @@ +# tests/rhosocial/activerecord_test/feature/basic/ddl/test_ddl_composite_annotations.py +"""Composite declarations on the field-level annotation surface. + +The anchor rule (D1): an explicit ``columns`` list is authoritative and the +annotated field must be its first (leftmost) column. Violations, unknown +columns and duplicate index names fail at class creation with ``TypeError``. +""" +from rhosocial.activerecord.backend.expression.statements import ( + ColumnForeignKeyConstraint, + ColumnNotNullConstraint, + ColumnUniqueConstraint, + TableForeignKeyConstraint, + TableUniqueConstraint, +) +from rhosocial.activerecord.backend.expression.objects.relation import Table +from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + IndexDefinition, +) +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.model import ActiveRecord +from rhosocial.activerecord.base import UseColumn, UseConstraint, UseIndex + +try: + from typing import Annotated +except ImportError: + from typing_extensions import Annotated + +import pytest + + +class CompositeIndexModel(ActiveRecord): + @classmethod + def table_name(cls): + return "composite_index_model" + + id: int + email: Annotated[str, UseIndex("idx_email_country", columns=["email", "country"])] + country: str + + +class CompositeUniqueModel(ActiveRecord): + @classmethod + def table_name(cls): + return "composite_unique_model" + + id: int + code: Annotated[ + str, + UseConstraint(ColumnUniqueConstraint, columns=["code", "status"], name="uq_code_status"), + ] + status: str + + +class CompositeForeignKeyModel(ActiveRecord): + @classmethod + def table_name(cls): + return "composite_foreign_key_model" + + id: int + order_id: Annotated[ + int, + UseConstraint( + ColumnForeignKeyConstraint, + columns=["order_id", "line_no"], + references=(Table(None, "orders"), ["order_no", "line_no"]), + name="fk_order_line", + ), + ] + line_no: int + + +class TestCompositeUseIndex: + """A ``columns`` list turns the field-level index into a composite one.""" + + def test_composite_definition_is_authoritative(self): + definitions = CompositeIndexModel.column_indexes("email") + assert len(definitions) == 1 + definition = definitions[0] + assert definition.name == "idx_email_country" + assert definition.columns == ["email", "country"] + + def test_non_anchor_field_has_no_index(self): + assert CompositeIndexModel.column_indexes("country") == [] + + def test_single_column_form_is_unchanged(self): + # Regression guard: the anchor-only form still yields a one-column + # definition without declaring ``columns``. + class WithSingleIndex(ActiveRecord): + @classmethod + def table_name(cls): + return "with_single_index" + + id: int + email: Annotated[str, UseIndex("idx_email", unique=True)] + + definitions = WithSingleIndex.column_indexes("email") + assert len(definitions) == 1 + assert definitions[0].columns == ["email"] + assert definitions[0].unique is True + + def test_columns_shorter_than_two_is_rejected(self): + with pytest.raises(TypeError): + class TooShort(ActiveRecord): + @classmethod + def table_name(cls): + return "too_short" + + id: int + email: Annotated[str, UseIndex("idx_email", columns=["email"])] + + def test_anchor_not_first_is_rejected_at_class_creation(self): + with pytest.raises(TypeError, match="leftmost"): + class AnchorMismatch(ActiveRecord): + @classmethod + def table_name(cls): + return "anchor_mismatch" + + id: int + country: Annotated[ + str, + UseIndex("idx_email_country", columns=["email", "country"]), + ] + email: str + + def test_unknown_column_is_rejected_at_class_creation(self): + with pytest.raises(TypeError, match="does not define"): + class UnknownColumn(ActiveRecord): + @classmethod + def table_name(cls): + return "unknown_column" + + id: int + email: Annotated[ + str, + UseIndex("idx_email_country", columns=["email", "nope"]), + ] + country: str + + def test_renamed_column_anchor_resolves_through_use_column(self): + # The anchor is the column, not the field: a renamed anchor matches + # ``columns[0]`` when the list names the database column. + class RenamedAnchor(ActiveRecord): + @classmethod + def table_name(cls): + return "renamed_anchor" + + id: int + email_address: Annotated[ + str, + UseColumn("email"), + UseIndex("idx_email_country", columns=["email", "country"]), + ] + country: str + + definitions = RenamedAnchor.column_indexes("email_address") + assert definitions[0].columns == ["email", "country"] + + def test_renamed_anchor_mismatch_is_rejected(self): + # ``columns`` names database columns: a list naming the Python field + # name does not match a renamed anchor's column. + with pytest.raises(TypeError, match="leftmost"): + class RenamedMismatch(ActiveRecord): + @classmethod + def table_name(cls): + return "renamed_mismatch" + + id: int + email_address: Annotated[ + str, + UseColumn("email"), + UseIndex("idx_email_country", columns=["email_address", "country"]), + ] + country: str + + +class TestIndexNameConflicts: + """Index names are unique across both declaration surfaces.""" + + def test_field_level_against_table_level_is_rejected(self): + with pytest.raises(TypeError, match="duplicate index name"): + class Collision(ActiveRecord): + @classmethod + def table_name(cls): + return "collision" + + __table_indexes__ = [ + IndexDefinition(None, name="dup", columns=["email"]), + ] + + id: int + email: Annotated[str, UseIndex("dup")] + + def test_field_level_against_field_level_is_rejected(self): + with pytest.raises(TypeError, match="duplicate index name"): + class Duplicate(ActiveRecord): + @classmethod + def table_name(cls): + return "duplicate" + + id: int + email: Annotated[str, UseIndex("dup")] + backup_email: Annotated[str, UseIndex("dup")] + + def test_table_level_internal_duplicate_is_rejected(self): + with pytest.raises(TypeError, match="duplicate index name"): + class TableDuplicate(ActiveRecord): + @classmethod + def table_name(cls): + return "table_duplicate" + + __table_indexes__ = [ + IndexDefinition(None, name="dup", columns=["email"]), + IndexDefinition(None, name="dup", columns=["name"]), + ] + + id: int + email: str + name: str + + def test_distinct_names_coexist(self): + class Coexisting(ActiveRecord): + @classmethod + def table_name(cls): + return "coexisting" + + __table_indexes__ = [ + IndexDefinition(None, name="idx_table_email", columns=["email"]), + ] + + id: int + email: Annotated[str, UseIndex("idx_field_email")] + + names = {index.name for index in Coexisting.table_indexes()} + names.update( + index.name + for index in Coexisting.column_indexes("email") + ) + assert names == {"idx_table_email", "idx_field_email"} + + +class TestCompositeUseConstraint: + """A multi-column ``UseConstraint`` surfaces as a table-level fact.""" + + def test_multi_column_unique_is_not_column_level(self): + # Only the implicit NOT NULL rides the column; the composite UNIQUE + # itself must not appear in the column definition. + constraints = CompositeUniqueModel.column_constraints("code") + assert all( + not isinstance(constraint.branch, ColumnUniqueConstraint) + for constraint in constraints + ) + + def test_multi_column_unique_surfaces_at_table_level(self): + constraints = [ + constraint for constraint in CompositeUniqueModel.table_constraints() + if isinstance(constraint.branch, TableUniqueConstraint) + ] + assert len(constraints) == 1 + constraint = constraints[0] + assert constraint.name == "uq_code_status" + assert constraint.branch.columns == ["code", "status"] + + def test_multi_column_foreign_key_surfaces_at_table_level(self): + constraints = [ + constraint for constraint in CompositeForeignKeyModel.table_constraints() + if isinstance(constraint.branch, TableForeignKeyConstraint) + ] + assert len(constraints) == 1 + constraint = constraints[0] + assert constraint.name == "fk_order_line" + assert constraint.branch.columns == ["order_id", "line_no"] + assert constraint.branch.references == (Table(None, "orders"), ["order_no", "line_no"]) + + def test_columns_rejects_branch_without_table_counterpart(self): + with pytest.raises(TypeError, match="table-level counterpart"): + UseConstraint(ColumnNotNullConstraint, columns=["a", "b"]) + + def test_single_column_form_is_unchanged(self): + # Regression guard: the anchor-only form still yields a column-level + # clause (alongside the implicit nullability constraint) and nothing + # at table level. + class WithColumnUnique(ActiveRecord): + @classmethod + def table_name(cls): + return "with_column_unique" + + id: int + code: Annotated[str, UseConstraint(ColumnUniqueConstraint, name="uq_code")] + + column_constraints = WithColumnUnique.column_constraints("code") + unique_constraints = [ + constraint for constraint in column_constraints + if isinstance(constraint.branch, ColumnUniqueConstraint) + ] + assert len(unique_constraints) == 1 + assert unique_constraints[0].name == "uq_code" + assert all( + not isinstance(constraint.branch, TableUniqueConstraint) + for constraint in WithColumnUnique.table_constraints() + ) + + def test_anchor_not_first_is_rejected_at_class_creation(self): + with pytest.raises(TypeError, match="leftmost"): + class AnchorMismatch(ActiveRecord): + @classmethod + def table_name(cls): + return "anchor_mismatch" + + id: int + status: Annotated[ + str, + UseConstraint(ColumnUniqueConstraint, columns=["code", "status"]), + ] + code: str + + def test_unknown_column_is_rejected_at_class_creation(self): + with pytest.raises(TypeError, match="does not define"): + class UnknownColumn(ActiveRecord): + @classmethod + def table_name(cls): + return "unknown_column" + + id: int + code: Annotated[ + str, + UseConstraint(ColumnUniqueConstraint, columns=["code", "nope"]), + ] + status: str + + +class TestDirectWriteFaceStaysReadable: + """D3b ordering: the annotation surface lands first, the direct-write + ``__table_indexes__`` face keeps working until its own turn.""" + + def test_table_indexes_declaration_is_returned_unchanged(self): + class WithTableIndex(ActiveRecord): + @classmethod + def table_name(cls): + return "with_table_index" + + __table_indexes__ = [ + IndexDefinition(None, name="idx_table_email", columns=["email"]), + ] + + id: int + email: str + + indexes = WithTableIndex.table_indexes() + assert len(indexes) == 1 + assert indexes[0].name == "idx_table_email" + + +class TestSqliteBackendStillConstructs: + """A backend still binds the model: declarations never break configure.""" + + def test_model_configures_and_accessors_resolve(self, tmp_path): + CompositeIndexModel.configure( + SQLiteConnectionConfig(database=str(tmp_path / "decl.db")), SQLiteBackend + ) + assert CompositeIndexModel.table_name() == "composite_index_model" + assert CompositeIndexModel.backend() is not None diff --git a/tests/rhosocial/activerecord_test/feature/basic/ddl/test_ddl_declaration_interfaces.py b/tests/rhosocial/activerecord_test/feature/basic/ddl/test_ddl_declaration_interfaces.py index 53a1e62c2..f5867cc83 100644 --- a/tests/rhosocial/activerecord_test/feature/basic/ddl/test_ddl_declaration_interfaces.py +++ b/tests/rhosocial/activerecord_test/feature/basic/ddl/test_ddl_declaration_interfaces.py @@ -1,3 +1,5 @@ +# tests/rhosocial/activerecord_test/feature/basic/ddl/test_ddl_declaration_interfaces.py +"""Tests for the dialect-free ActiveRecord DDLSource declaration contract.""" from rhosocial.activerecord.backend.expression.statements import ( ColumnNotNullConstraint, ColumnNullableConstraint, @@ -15,11 +17,13 @@ DDLFieldMetadata, DDLSource, IdentityAttribute, + ObjectDeclaration, UseColumnAttributes, UseComment, UseConstraint, UseGeneratedColumn, UseIndex, + UsePartitionKey, UseSqlType, ) from rhosocial.activerecord.backend.expression.types import DataType, TextType @@ -33,29 +37,34 @@ ) from rhosocial.activerecord.backend.expression.core import Column from typing import Optional -# tests/rhosocial/activerecord_test/feature/basic/ddl/test_ddl_declaration_interfaces.py -"""Tests for the dialect-free ActiveRecord DDLSource declaration contract.""" +import pytest try: from typing import Annotated except ImportError: from typing_extensions import Annotated -import pytest - class Plain(ActiveRecord): - __table_name__ = "plain" + @classmethod + def table_name(cls): + return "plain" id: int name: str class Composite(ActiveRecord): - __table_name__ = "composite" - __primary_key__ = ("order_id", "product_id") + @classmethod + def table_name(cls): + return "composite" + + @classmethod + def primary_key(cls): + return ("order_id", "product_id") + __table_indexes__ = [ IndexDefinition(None, name="idx_pair", columns=["order_id", "product_id"]), ] @@ -66,8 +75,14 @@ class Composite(ActiveRecord): class CompositeWithDeclaredPrimaryKey(ActiveRecord): - __table_name__ = "composite_with_declared_primary_key" - __primary_key__ = ("order_id", "product_id") + @classmethod + def table_name(cls): + return "composite_with_declared_primary_key" + + @classmethod + def primary_key(cls): + return ("order_id", "product_id") + __table_constraints__ = [ TableConstraint(None, branch=TablePrimaryKeyConstraint(None, ["order_id", "product_id"])), ] @@ -77,7 +92,10 @@ class CompositeWithDeclaredPrimaryKey(ActiveRecord): class WithTableConstraint(ActiveRecord): - __table_name__ = "with_table_constraint" + @classmethod + def table_name(cls): + return "with_table_constraint" + __table_constraints__ = [ TableConstraint(None, branch=TableUniqueConstraint(None, ["code", "status"]), name="uq_code_status"), ] @@ -88,7 +106,10 @@ class WithTableConstraint(ActiveRecord): class WithFieldIndex(ActiveRecord): - __table_name__ = "with_field_index" + @classmethod + def table_name(cls): + return "with_field_index" + __table_indexes__ = [ IndexDefinition(None, name="idx_table_email", columns=["email"]), ] @@ -98,7 +119,9 @@ class WithFieldIndex(ActiveRecord): class WithAttributes(ActiveRecord): - __table_name__ = "with_attributes" + @classmethod + def table_name(cls): + return "with_attributes" id: int name: Annotated[str, UseColumnAttributes(CollationAttribute(name="NOCASE"))] @@ -110,7 +133,9 @@ class WithAttributes(ActiveRecord): class WithNullable(ActiveRecord): - __table_name__ = "with_nullable" + @classmethod + def table_name(cls): + return "with_nullable" id: int nickname: Annotated[Optional[str], UseConstraint(ColumnNullableConstraint)] = None @@ -119,7 +144,9 @@ class WithNullable(ActiveRecord): class WithDerivedDeclarations(ActiveRecord): - __table_name__ = "with_derived_declarations" + @classmethod + def table_name(cls): + return "with_derived_declarations" id: int label: Annotated[str, UseComment("display label")] @@ -136,6 +163,7 @@ class WithDerivedDeclarations(ActiveRecord): UseColumnAttributes: "attributed", UseComment: "commented", UseGeneratedColumn: "generated", + UsePartitionKey: "partition_keyed", } @@ -143,7 +171,9 @@ class ForeignAttribute(ColumnAttribute): kind = "foreign_custom" class WithForeignAttribute(ActiveRecord): - __table_name__ = "with_foreign_attribute" + @classmethod + def table_name(cls): + return "with_foreign_attribute" id: int name: Annotated[str, UseColumnAttributes(ForeignAttribute())] @@ -154,7 +184,9 @@ class ForeignPartitionClause(PartitionClause): class WithForeignPartition(ActiveRecord): - __table_name__ = "with_foreign_partition" + @classmethod + def table_name(cls): + return "with_foreign_partition" id: int @@ -172,7 +204,9 @@ class UnhandledAnnotation(DDLAnnotation): class WithUnhandledAnnotation(ActiveRecord): - __table_name__ = "with_unhandled_annotation" + @classmethod + def table_name(cls): + return "with_unhandled_annotation" id: int value: Annotated[str, UnhandledAnnotation()] @@ -181,7 +215,9 @@ class WithUnhandledAnnotation(ActiveRecord): class EveryRegisteredMarker(ActiveRecord): """One field per core marker, so the registry can be walked against them.""" - __table_name__ = "every_registered_marker" + @classmethod + def table_name(cls): + return "every_registered_marker" id: int sql_typed: Annotated[str, UseSqlType(TextType())] @@ -190,6 +226,7 @@ class EveryRegisteredMarker(ActiveRecord): attributed: Annotated[str, UseColumnAttributes(CollationAttribute(name="NOCASE"))] commented: Annotated[str, UseComment("a comment")] generated: Annotated[str, UseGeneratedColumn(lambda dialect: object())] + partition_keyed: Annotated[str, UsePartitionKey()] def test_activerecord_satisfies_ddl_source(): @@ -245,6 +282,7 @@ def test_the_marker_registry_is_what_the_declaration_interface_reads(): UseColumnAttributes: ("column_attributes", False), UseComment: ("column_comment", True), UseGeneratedColumn: ("generated_column", True), + UsePartitionKey: ("partition_key", True), } assert MARKER_REGISTRY == expected, ( f"the registry writes {sorted(attr for attr, _ in MARKER_REGISTRY.values())} " @@ -395,6 +433,21 @@ def test_default_table_interfaces_are_empty_or_none(): assert Plain.table_inherits() is None assert Plain.table_tablespace() is None assert Plain.create_table_statement_classes() is None + assert Plain.object_type() is ObjectDeclaration.TABLE + + +def test_table_level_accessors_run_the_unhandled_annotation_gate(): + """P1-3: the direct-write table faces answer to the same gate. + + Field-level accessors all call ``_validate_ddl_annotations`` before + reading; ``table_indexes`` and ``table_constraints`` now do too, so a + model carrying an unconsumed annotation cannot slip a table-level read + past the check that every other accessor applies. + """ + with pytest.raises(TypeError, match="explicit handler"): + WithUnhandledAnnotation.table_indexes() + with pytest.raises(TypeError, match="explicit handler"): + WithUnhandledAnnotation.table_constraints() def test_unhandled_annotation_requires_explicit_handler(): diff --git a/tests/rhosocial/activerecord_test/feature/basic/ddl/test_ddl_partition_key.py b/tests/rhosocial/activerecord_test/feature/basic/ddl/test_ddl_partition_key.py new file mode 100644 index 000000000..e0d55520e --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/basic/ddl/test_ddl_partition_key.py @@ -0,0 +1,370 @@ +# tests/rhosocial/activerecord_test/feature/basic/ddl/test_ddl_partition_key.py +"""Partition key declaration on the field-level annotation surface. + +``UsePartitionKey`` marks a field as a partition-key member; the extraction +lands on ``__partition_key__`` in field-declaration order, UseColumn-aware. +Exactly one of the two faces (annotations, direct dunder) may declare the +key; the query layer warns when a lookup skips every partition column, and +the CLI says the same out loud at filter time. +""" +from rhosocial.activerecord.backend.expression import ( + Column, + ComparisonPredicate, + Literal, +) +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect +from rhosocial.activerecord.model import ActiveRecord +from rhosocial.activerecord.base import UseColumn, UsePartitionKey +from rhosocial.activerecord.query.active_query import _warn_partial_key_scope +from rhosocial.activerecord.cli.runtime import apply_filters + +import logging +import warnings +from types import SimpleNamespace + +try: + from typing import Annotated +except ImportError: + from typing_extensions import Annotated + +import pytest + + +class PartitionedLog(ActiveRecord): + """Annotated partition key; the primary key contains every member.""" + + @classmethod + def table_name(cls): + return "partitioned_logs" + + @classmethod + def primary_key(cls): + return ("id", "bucket") + + id: int + bucket: Annotated[str, UsePartitionKey()] + created_at: Annotated[str, UseColumn("created_day"), UsePartitionKey()] + + +class DirectPartitioned(ActiveRecord): + """Direct-write partition key, as an introspection-generated model emits.""" + + @classmethod + def table_name(cls): + return "direct_partitioned" + + @classmethod + def primary_key(cls): + return ("id", "day") + + __partition_key__ = ("day",) + + id: int + day: str + + +class Unpartitioned(ActiveRecord): + @classmethod + def table_name(cls): + return "unpartitioned" + + id: int + name: str + + +class TestDeclarationSurface: + """The four cases of §4: annotations, dunder, both, neither.""" + + def test_annotated_only_extracts_in_field_order(self): + # Declaration order, UseColumn-aware: the renamed field contributes + # its column name, not the Python field name. + assert PartitionedLog.__partition_key__ == ("bucket", "created_day") + + def test_annotated_only_accessor_mirrors_pk_accessor(self): + assert PartitionedLog.partition_key_columns() == ("bucket", "created_day") + + def test_direct_declaration_adopted_as_written(self): + assert DirectPartitioned.partition_key_columns() == ("day",) + + def test_both_declared_is_rejected(self): + with pytest.raises(TypeError, match="twice"): + class Both(ActiveRecord): + @classmethod + def table_name(cls): + return "both_declared" + + __partition_key__ = ("bucket",) + + id: int + bucket: Annotated[str, UsePartitionKey()] + + def test_neither_declared_is_not_partitioned(self): + assert Unpartitioned.partition_key_columns() == () + # The extraction writes nothing: the dunder stays the interface default. + assert Unpartitioned.__dict__.get("__partition_key__") is None + + +class TestPartitionKeyValidation: + """Direct declarations are validated; containment gaps are announced.""" + + def test_bare_string_is_rejected_tuple_required(self): + with pytest.raises(TypeError, match="must be a tuple"): + class BareString(ActiveRecord): + @classmethod + def table_name(cls): + return "bare_string" + + __partition_key__ = "day" + + id: int + day: str + + def test_expression_key_entries_are_rejected(self): + # The first round accepts plain column tuples only: a non-string + # entry (an expression key would arrive this way) is refused. + with pytest.raises(TypeError, match="non-empty column-name strings"): + class ExpressionKey(ActiveRecord): + @classmethod + def table_name(cls): + return "expression_key" + + __partition_key__ = ("day", lambda dialect: dialect) + + id: int + day: str + + def test_unknown_column_is_rejected(self): + with pytest.raises(TypeError, match="does not define"): + class UnknownColumn(ActiveRecord): + @classmethod + def table_name(cls): + return "unknown_partition_column" + + __partition_key__ = ("nope",) + + id: int + day: str + + def test_partition_key_outside_pk_warns(self): + with pytest.warns(UserWarning, match="partition key"): + class LoosePartition(ActiveRecord): + @classmethod + def table_name(cls): + return "loose_partition" + + @classmethod + def primary_key(cls): + return "id" + + id: int + region: Annotated[str, UsePartitionKey()] + + def test_direct_declaration_outside_pk_also_warns(self): + with pytest.warns(UserWarning, match="partition key"): + class LooseDirect(ActiveRecord): + @classmethod + def table_name(cls): + return "loose_direct" + + @classmethod + def primary_key(cls): + return "id" + + __partition_key__ = ("day",) + + id: int + day: str + + def test_partition_key_inside_pk_is_silent(self): + with warnings.catch_warnings(record=True): + warnings.simplefilter("error", UserWarning) + class CoveredPartition(ActiveRecord): + @classmethod + def table_name(cls): + return "covered_partition" + + @classmethod + def primary_key(cls): + return ("id", "region") + + id: int + region: Annotated[str, UsePartitionKey()] + + assert CoveredPartition.partition_key_columns() == ("region",) + + +class _QueryStub: + """Minimal query surface ``_warn_partial_key_scope`` reads.""" + + def __init__(self, model, where_clause=None): + self.model_class = model + self.where_clause = where_clause + self.records = [] + + def _log(self, level, msg, *args, **kwargs): + self.records.append((level, msg)) + + +_DIALECT = SQLiteDialect() + + +def _where(*pairs): + """Predicates over column names, as a WHERE tree fragment.""" + return tuple( + ComparisonPredicate(_DIALECT, "=", Column(_DIALECT, column), Literal(_DIALECT, value)) + for column, value in pairs + ) + + +class TestQueryPartitionScopeWarning: + """The query layer warns when a lookup skips every partition column.""" + + def test_partitioned_model_without_partition_column_warns(self): + query = _QueryStub(PartitionedLog) + _warn_partial_key_scope(query) + assert any( + level == logging.WARNING and "partition key" in msg + for level, msg in query.records + ) + + def test_partition_column_constrained_is_silent(self): + # ``bucket`` is a primary-key member too, so constraining it alone + # still leaves ``id`` -- both warnings must be accounted for. + query = _QueryStub( + PartitionedLog, + _where(("bucket", "x"), ("id", 1)), + ) + _warn_partial_key_scope(query) + assert query.records == [] + + def test_pk_partial_warning_survives_next_to_partition_check(self): + # Partition column constrained, primary key only half so: the PK + # warning still fires (and the partition one stays silent). The key + # is application-assigned here -- the PK check reads the caller's + # key columns, which an auto-generated key would not report. + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + class NaturalKeyPartitioned(ActiveRecord): + @classmethod + def table_name(cls): + return "natural_key_partitioned" + + @classmethod + def primary_key(cls): + return ("code", "bucket") + + @classmethod + def pk_auto_generated(cls): + return False + + code: str + bucket: Annotated[str, UsePartitionKey()] + + query = _QueryStub(NaturalKeyPartitioned, _where(("bucket", "x"))) + _warn_partial_key_scope(query) + messages = [msg for _, msg in query.records] + assert any("leaves key column(s) code" in msg for msg in messages) + assert not any("partition key" in msg for msg in messages) + + def test_keyless_partitioned_model_still_warns(self): + # The independence case: no primary key means the PK check + # early-returns, and the partition check must not be swallowed. + with pytest.warns(UserWarning, match="partition key"): + class KeylessPartitioned(ActiveRecord): + @classmethod + def table_name(cls): + return "keyless_partitioned" + + @classmethod + def primary_key(cls): + return None + + __partition_key__ = ("day",) + + id: int + day: str + + query = _QueryStub(KeylessPartitioned) + _warn_partial_key_scope(query) + assert any( + level == logging.WARNING and "partition key" in msg + for level, msg in query.records + ) + + def test_non_partitioned_model_is_silent(self): + query = _QueryStub(Unpartitioned) + _warn_partial_key_scope(query) + assert query.records == [] + + +class _FilterQueryStub: + """Records ``where`` calls; the other filter options stay untouched.""" + + def __init__(self): + self.predicates = [] + + def where(self, predicate): + self.predicates.append(predicate) + return self + + +def _args(where=None, quiet=False): + return SimpleNamespace(where=where, order_by=None, select=None, quiet=quiet) + + +class TestCliPartitionHint: + """The CLI says the partition-scope difference out loud at filter time.""" + + @pytest.fixture + def cli_model(self, tmp_path): + class CliPartitionedLog(ActiveRecord): + @classmethod + def table_name(cls): + return "cli_partitioned_logs" + + @classmethod + def primary_key(cls): + return ("id", "bucket") + + id: int + bucket: Annotated[str, UsePartitionKey()] + + CliPartitionedLog.configure( + SQLiteConnectionConfig(database=str(tmp_path / "cli_pk.db")), SQLiteBackend + ) + return CliPartitionedLog + + def test_hint_printed_when_where_skips_partition(self, cli_model, capsys): + apply_filters(_FilterQueryStub(), cli_model, _args(["id=1"])) + stderr = capsys.readouterr().err + assert "partitioned by" in stderr + assert "bucket" in stderr + + def test_no_hint_when_partition_column_constrained(self, cli_model, capsys): + apply_filters(_FilterQueryStub(), cli_model, _args(["bucket=jobs"])) + assert capsys.readouterr().err == "" + + def test_no_hint_without_where(self, cli_model, capsys): + apply_filters(_FilterQueryStub(), cli_model, _args()) + assert capsys.readouterr().err == "" + + def test_quiet_suppresses_the_hint(self, cli_model, capsys): + apply_filters(_FilterQueryStub(), cli_model, _args(["id=1"], quiet=True)) + assert capsys.readouterr().err == "" + + def test_no_hint_for_unpartitioned_model(self, tmp_path, capsys): + class CliPlain(ActiveRecord): + @classmethod + def table_name(cls): + return "cli_plain" + + id: int + name: str + + CliPlain.configure( + SQLiteConnectionConfig(database=str(tmp_path / "cli_plain.db")), SQLiteBackend + ) + apply_filters(_FilterQueryStub(), CliPlain, _args(["id=1"])) + assert capsys.readouterr().err == "" diff --git a/tests/rhosocial/activerecord_test/feature/basic/ddl/test_soft_delete_partial_unique.py b/tests/rhosocial/activerecord_test/feature/basic/ddl/test_soft_delete_partial_unique.py new file mode 100644 index 000000000..fe61842c2 --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/basic/ddl/test_soft_delete_partial_unique.py @@ -0,0 +1,119 @@ +# tests/rhosocial/activerecord_test/feature/basic/ddl/test_soft_delete_partial_unique.py +"""The recipe: a soft-deleted table's business key is a partial unique index. + +Zero new API -- this pins how two existing declarations compose, so the +combination cannot silently stop working. ``UseIndex(partial_condition=...)`` +supplies the ``WHERE deleted_at IS NULL`` predicate and ``SoftDeleteMixin`` +supplies the column it names. +""" +from typing import ClassVar, Optional + +from rhosocial.activerecord.backend.expression import Column, IsNullPredicate +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.backend.impl.sqlite.dialect import SQLiteDialect +from rhosocial.activerecord.base import FieldProxy, UseIndex +from rhosocial.activerecord.field import DefaultSoftDeleteMixin +from rhosocial.activerecord.model import ActiveRecord + +try: + from typing import Annotated +except ImportError: + from typing_extensions import Annotated + +import pytest + + +class SoftDeletedEmail(DefaultSoftDeleteMixin, ActiveRecord): + """One row per address, but only among the rows that are not deleted.""" + + @classmethod + def table_name(cls): + return "soft_delete_partial_unique" + + c: ClassVar[FieldProxy] = FieldProxy() + + id: Optional[int] = None + email: Annotated[ + str, + UseIndex( + "uq_soft_delete_partial_unique_email", + unique=True, + partial_condition=lambda dialect: IsNullPredicate( + dialect, Column(dialect, "deleted_at") + ), + ), + ] + label: str = "" + + +def _resolved_index(dialect): + """The declared index with its lazy condition resolved, as DDL would.""" + marker = SoftDeletedEmail.__table_ddl_fields__["email"].indexes[0] + definition = SoftDeletedEmail.index_definition(marker, "email") + condition = definition.partial_condition + if callable(condition): + condition = condition(dialect) + return definition, condition + + +class TestIndexDeclaration: + def test_partial_condition_is_carried(self): + definition, _ = _resolved_index(SQLiteDialect()) + assert definition.unique is True + assert definition.partial_condition is not None + + def test_condition_names_the_soft_delete_column(self): + # The marker carries a factory, not a rendered expression: no + # dialect exists when the class body runs. Resolving it here is what + # the DDL generator does at build time. + _, condition = _resolved_index(SQLiteDialect()) + rendered, params = condition.to_sql() + assert "deleted_at" in rendered + assert "IS NULL" in rendered.upper() + + def test_soft_delete_column_is_the_declared_locator(self): + # The recipe is only correct while both halves name the same column. + assert SoftDeletedEmail.deleted_at_field() == "deleted_at" + + +class TestRoundTrip: + @pytest.fixture + def table(self, tmp_path): + SoftDeletedEmail.configure( + SQLiteConnectionConfig(database=str(tmp_path / "partial.db")), SQLiteBackend + ) + backend = SoftDeletedEmail.backend() + backend.execute( + 'CREATE TABLE "soft_delete_partial_unique" (' + '"id" INTEGER PRIMARY KEY AUTOINCREMENT, ' + '"email" TEXT NOT NULL, ' + '"label" TEXT NOT NULL DEFAULT \'\', ' + '"deleted_at" TEXT)' + ) + definition, condition = _resolved_index(backend.dialect) + condition_sql, condition_params = condition.to_sql() + backend.execute( + 'CREATE UNIQUE INDEX "uq_soft_delete_partial_unique_email" ' + 'ON "soft_delete_partial_unique" ("email") ' + f"WHERE {condition_sql}", + condition_params, + ) + return SoftDeletedEmail + + def test_live_row_then_soft_delete_then_reuse(self, table): + first = table(email="a@example.com", label="first") + first.save() + first.delete() # soft delete: the row stays, deleted_at is set + + second = table(email="a@example.com", label="second") + second.save() # allowed: the older row is no longer live + assert second.id is not None + assert table.query().where(table.c.label == "second").one().email == "a@example.com" + + def test_two_live_rows_with_one_address_are_refused(self, table): + from rhosocial.activerecord.backend.errors import DatabaseError + + table(email="b@example.com", label="one").save() + with pytest.raises(DatabaseError): + table(email="b@example.com", label="two").save() diff --git a/tests/rhosocial/activerecord_test/feature/basic/fields/test_derived_field.py b/tests/rhosocial/activerecord_test/feature/basic/fields/test_derived_field.py index 87b1cbd35..77535a4bf 100644 --- a/tests/rhosocial/activerecord_test/feature/basic/fields/test_derived_field.py +++ b/tests/rhosocial/activerecord_test/feature/basic/fields/test_derived_field.py @@ -311,7 +311,10 @@ def test_use_column_conflict_raises(self): """UseColumn on derived field must not duplicate a regular field's column name.""" with pytest.raises(TypeError, match="conflicts with a regular field's column name"): class ConflictModel(ActiveRecord): - __table_name__ = "conflict" + @classmethod + def table_name(cls): + return "conflict" + id: Optional[int] = None discount_rate: Annotated[float, UseColumn("disc")] discounted: ClassVar[Annotated[float, DerivedField( @@ -321,7 +324,10 @@ class ConflictModel(ActiveRecord): def test_use_column_no_conflict_different_names(self): """Different column names should not conflict.""" class NoConflict(ActiveRecord): - __table_name__ = "no_conflict" + @classmethod + def table_name(cls): + return "no_conflict" + id: Optional[int] = None discount_rate: Annotated[float, UseColumn("rate")] discounted: ClassVar[Annotated[float, DerivedField( diff --git a/tests/rhosocial/activerecord_test/feature/basic/fields/test_derived_field_async.py b/tests/rhosocial/activerecord_test/feature/basic/fields/test_derived_field_async.py index 7064b54af..e21a9a6ab 100644 --- a/tests/rhosocial/activerecord_test/feature/basic/fields/test_derived_field_async.py +++ b/tests/rhosocial/activerecord_test/feature/basic/fields/test_derived_field_async.py @@ -311,7 +311,10 @@ async def test_use_column_conflict_raises(self): """UseColumn on derived field must not duplicate a regular field's column name.""" with pytest.raises(TypeError, match="conflicts with a regular field's column name"): class ConflictModel(ActiveRecord): - __table_name__ = "conflict" + @classmethod + def table_name(cls): + return "conflict" + id: Optional[int] = None discount_rate: Annotated[float, UseColumn("disc")] discounted: ClassVar[Annotated[float, DerivedField( @@ -321,7 +324,10 @@ class ConflictModel(ActiveRecord): async def test_use_column_no_conflict_different_names(self): """Different column names should not conflict.""" class NoConflict(ActiveRecord): - __table_name__ = "no_conflict" + @classmethod + def table_name(cls): + return "no_conflict" + id: Optional[int] = None discount_rate: Annotated[float, UseColumn("rate")] discounted: ClassVar[Annotated[float, DerivedField( diff --git a/tests/rhosocial/activerecord_test/feature/basic/fields/test_key_generation_and_natural_key.py b/tests/rhosocial/activerecord_test/feature/basic/fields/test_key_generation_and_natural_key.py new file mode 100644 index 000000000..aa059fe0e --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/basic/fields/test_key_generation_and_natural_key.py @@ -0,0 +1,244 @@ +# tests/rhosocial/activerecord_test/feature/basic/fields/test_key_generation_and_natural_key.py +"""UUID version selection and the declared natural key. + +Both are declaration-surface facts (A1 / A2 of the governance plan): the +UUID variant is chosen by overriding ``uuid_version()`` and the natural +key by overriding ``natural_key()``. +""" +import uuid +from typing import ClassVar, Optional + +from rhosocial.activerecord.backend.impl.sqlite.backend import SQLiteBackend +from rhosocial.activerecord.backend.impl.sqlite.config import SQLiteConnectionConfig +from rhosocial.activerecord.base import FieldProxy +from rhosocial.activerecord.field import UUIDMixin +from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord + +import pytest + +HAS_UUID7 = getattr(uuid, "uuid7", None) is not None + + +class Uuid4Record(UUIDMixin, ActiveRecord): + @classmethod + def table_name(cls): + return "a1_uuid4" + + c: ClassVar[FieldProxy] = FieldProxy() + + label: str = "" + + +class Uuid7Record(UUIDMixin, ActiveRecord): + @classmethod + def table_name(cls): + return "a1_uuid7" + + @classmethod + def uuid_version(cls): + return "uuid7" + + c: ClassVar[FieldProxy] = FieldProxy() + + label: str = "" + + +class TestUuidVersionSelection: + def test_default_generates_v4(self): + assert Uuid4Record.uuid_version() == "uuid4" + record = Uuid4Record() + assert record.id.version == 4 + + def test_override_selects_v7(self): + assert Uuid7Record.uuid_version() == "uuid7" + + @pytest.mark.skipif(not HAS_UUID7, reason="uuid.uuid7 needs Python 3.14+") + def test_v7_is_generated_with_its_version(self): + record = Uuid7Record() + assert record.id.version == 7 + + @pytest.mark.skipif(HAS_UUID7, reason="uuid.uuid7 is available here") + def test_v7_without_stdlib_support_says_what_is_missing(self): + # The probe, not an import: the message must name the declaration + # and the interpreter requirement, because that is what the reader + # needs to fix it. + with pytest.raises(RuntimeError, match="Python 3.14"): + Uuid7Record() + + def test_unknown_variant_is_rejected(self): + class WrongVariant(UUIDMixin, ActiveRecord): + @classmethod + def table_name(cls): + return "a1_uuid_bad" + + @classmethod + def uuid_version(cls): + return "uuid9" + + c: ClassVar[FieldProxy] = FieldProxy() + + with pytest.raises(ValueError, match="uuid4"): + WrongVariant() + + def test_declared_key_value_still_wins(self): + given = uuid.uuid4() + record = Uuid4Record(id=given) + assert record.id == given + + +class NamedAccount(ActiveRecord): + @classmethod + def table_name(cls): + return "a2_named_accounts" + + @classmethod + def natural_key(cls): + return ("tenant", "external_id") + + c: ClassVar[FieldProxy] = FieldProxy() + + id: Optional[int] = None + tenant: str + external_id: str + note: str = "" + + +class UnnamedAccount(ActiveRecord): + @classmethod + def table_name(cls): + return "a2_unnamed_accounts" + + c: ClassVar[FieldProxy] = FieldProxy() + + id: Optional[int] = None + tenant: str + external_id: str + + +class TestNaturalKeyDeclaration: + def test_default_is_empty(self): + assert UnnamedAccount.natural_key() == () + + def test_override_is_reported(self): + assert NamedAccount.natural_key() == ("tenant", "external_id") + + def test_unknown_field_is_rejected_at_class_creation(self): + with pytest.raises(TypeError, match="does not define"): + class BadKey(ActiveRecord): + @classmethod + def table_name(cls): + return "a2_bad_key" + + @classmethod + def natural_key(cls): + return ("nope",) + + c: ClassVar[FieldProxy] = FieldProxy() + + id: Optional[int] = None + + def test_repeated_field_is_rejected_at_class_creation(self): + with pytest.raises(TypeError, match="repeats"): + class RepeatedKey(ActiveRecord): + @classmethod + def table_name(cls): + return "a2_repeated_key" + + @classmethod + def natural_key(cls): + return ("tenant", "tenant") + + c: ClassVar[FieldProxy] = FieldProxy() + + id: Optional[int] = None + tenant: str + + +class AsyncNamedAccount(AsyncActiveRecord): + @classmethod + def table_name(cls): + return "a2_named_accounts_async" + + @classmethod + def natural_key(cls): + return ("tenant", "external_id") + + c: ClassVar[FieldProxy] = FieldProxy() + + id: Optional[int] = None + tenant: str + external_id: str + note: str = "" + + +NAMED_DDL = ( + 'CREATE TABLE "{table}" (' + '"id" INTEGER PRIMARY KEY AUTOINCREMENT, ' + '"tenant" TEXT NOT NULL, ' + '"external_id" TEXT NOT NULL, ' + '"note" TEXT NOT NULL DEFAULT \'\')' +) + + +class TestNaturalKeyConsumption: + @pytest.fixture + def configured(self, tmp_path): + NamedAccount.configure( + SQLiteConnectionConfig(database=str(tmp_path / "natural.db")), SQLiteBackend + ) + NamedAccount.backend().execute(NAMED_DDL.format(table="a2_named_accounts")) + return NamedAccount + + def test_empty_lookup_uses_the_declared_key(self, configured): + row, created = configured.find_one_or_create( + defaults={"tenant": "acme", "external_id": "X-1", "note": "first"} + ) + assert created is True + assert row.tenant == "acme" and row.external_id == "X-1" + + again, created_again = configured.find_one_or_create( + defaults={"tenant": "acme", "external_id": "X-1", "note": "ignored"} + ) + assert created_again is False + assert again.id == row.id + assert again.note == "first" + + def test_explicit_lookup_still_wins(self, configured): + first, _ = configured.find_one_or_create( + defaults={"tenant": "acme", "external_id": "X-2"} + ) + row, created = configured.find_one_or_create( + tenant="acme", external_id="X-2", defaults={"note": "second"} + ) + assert created is False + assert row.id == first.id + + def test_missing_key_member_names_what_is_absent(self, configured): + with pytest.raises(ValueError, match="external_id"): + configured.find_one_or_create(defaults={"tenant": "acme"}) + + def test_undeclared_model_keeps_requiring_a_lookup(self, tmp_path): + UnnamedAccount.configure( + SQLiteConnectionConfig(database=str(tmp_path / "unnamed.db")), SQLiteBackend + ) + with pytest.raises(ValueError, match="at least one lookup field"): + UnnamedAccount.find_one_or_create(defaults={"tenant": "acme"}) + + @pytest.mark.asyncio + async def test_async_twin_uses_the_declared_key(self, async_user_class): + # Same fixture-based backend as the rest of the async tests: a + # freshly configured async backend would open its own loop-bound + # connection, while this one is already wired to the test loop. + backend = async_user_class.backend() + AsyncNamedAccount.__backend__ = backend + await backend.executescript(NAMED_DDL.format(table="a2_named_accounts_async")) + + row, created = await AsyncNamedAccount.find_one_or_create( + defaults={"tenant": "acme", "external_id": "X-9"} + ) + assert created is True + again, created_again = await AsyncNamedAccount.find_one_or_create( + defaults={"tenant": "acme", "external_id": "X-9"} + ) + assert created_again is False + assert again.id == row.id diff --git a/tests/rhosocial/activerecord_test/feature/basic/find_one_or_create/test_find_one_or_create.py b/tests/rhosocial/activerecord_test/feature/basic/find_one_or_create/test_find_one_or_create.py index 4b65c57ca..fd8ee07b7 100644 --- a/tests/rhosocial/activerecord_test/feature/basic/find_one_or_create/test_find_one_or_create.py +++ b/tests/rhosocial/activerecord_test/feature/basic/find_one_or_create/test_find_one_or_create.py @@ -17,7 +17,10 @@ class FindOrCreateItem(IntegerPKMixin, ActiveRecord): """Natural-key model with a database-generated primary key.""" - __table_name__ = "find_or_create_items" + @classmethod + def table_name(cls): + return "find_or_create_items" + c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None diff --git a/tests/rhosocial/activerecord_test/feature/basic/find_one_or_create/test_find_one_or_create_async.py b/tests/rhosocial/activerecord_test/feature/basic/find_one_or_create/test_find_one_or_create_async.py index 34a2f49d5..527be7bfb 100644 --- a/tests/rhosocial/activerecord_test/feature/basic/find_one_or_create/test_find_one_or_create_async.py +++ b/tests/rhosocial/activerecord_test/feature/basic/find_one_or_create/test_find_one_or_create_async.py @@ -17,7 +17,10 @@ class AsyncFindOrCreateItem(IntegerPKMixin, AsyncActiveRecord): """Natural-key model with a database-generated primary key.""" - __table_name__ = "find_or_create_items" + @classmethod + def table_name(cls): + return "find_or_create_items" + c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None diff --git a/tests/rhosocial/activerecord_test/feature/basic/fixtures/models.py b/tests/rhosocial/activerecord_test/feature/basic/fixtures/models.py index 279822b64..b4e5b6883 100644 --- a/tests/rhosocial/activerecord_test/feature/basic/fixtures/models.py +++ b/tests/rhosocial/activerecord_test/feature/basic/fixtures/models.py @@ -31,7 +31,9 @@ class TypeCase(UUIDMixin, ActiveRecord): """A model with a wide variety of data types to test database type handling.""" - __table_name__ = "type_cases" + @classmethod + def table_name(cls): + return "type_cases" username: str email: str @@ -54,7 +56,10 @@ class TypeCase(UUIDMixin, ActiveRecord): class AsyncTypeCase(UUIDMixin, AsyncActiveRecord): """A model with a wide variety of data types to test database type handling.""" - __table_name__ = "type_cases" + @classmethod + def table_name(cls): + return "type_cases" + c: ClassVar[FieldProxy] = FieldProxy() username: str @@ -78,7 +83,9 @@ class AsyncTypeCase(UUIDMixin, AsyncActiveRecord): class TypeTestModel(UUIDMixin, ActiveRecord): """Model class for testing various field types""" - __table_name__ = "type_tests" + @classmethod + def table_name(cls): + return "type_tests" # UUID primary key provided by UUIDMixin string_field: str = Field(default="test string") @@ -92,7 +99,10 @@ class TypeTestModel(UUIDMixin, ActiveRecord): class AsyncTypeTestModel(UUIDMixin, AsyncActiveRecord): """Model class for testing various field types""" - __table_name__ = "type_tests" + @classmethod + def table_name(cls): + return "type_tests" + c: ClassVar[FieldProxy] = FieldProxy() # UUID primary key provided by UUIDMixin @@ -108,7 +118,10 @@ class AsyncTypeTestModel(UUIDMixin, AsyncActiveRecord): class User(IntegerPKMixin, TimestampMixin, ActiveRecord): """A standard User model for general CRUD operation testing.""" - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + c: ClassVar[FieldProxy] = FieldProxy() # The IntegerPKMixin is expected to handle the `id` field. @@ -121,7 +134,10 @@ class User(IntegerPKMixin, TimestampMixin, ActiveRecord): class AsyncUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """A standard User model for general CRUD operation testing.""" - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + c: ClassVar[FieldProxy] = FieldProxy() # The IntegerPKMixin is expected to handle the `id` field. @@ -137,7 +153,10 @@ class ValidatedFieldUser(IntegerPKMixin, ActiveRecord): A User model with specific, custom field validators to test the framework's validation handling. """ - __table_name__ = "validated_field_users" + @classmethod + def table_name(cls): + return "validated_field_users" + c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -170,7 +189,10 @@ class AsyncValidatedFieldUser(IntegerPKMixin, AsyncActiveRecord): A User model with specific, custom field validators to test the framework's validation handling. """ - __table_name__ = "validated_field_users" + @classmethod + def table_name(cls): + return "validated_field_users" + c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -200,7 +222,9 @@ def validate_credit_score(cls, value): class ValidatedUser(IntegerPKMixin, ActiveRecord): """User model for validation testing""" - __table_name__ = "validated_users" + @classmethod + def table_name(cls): + return "validated_users" id: Optional[int] = None username: str = Field(..., min_length=3, max_length=50) @@ -224,7 +248,10 @@ def validate_record(cls, instance: 'ValidatedUser') -> None: class AsyncValidatedUser(IntegerPKMixin, AsyncActiveRecord): """User model for validation testing""" - __table_name__ = "validated_users" + @classmethod + def table_name(cls): + return "validated_users" + c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -283,14 +310,18 @@ def validate_period(self): class PydanticValidatedModel(PydanticValidatedFieldsMixin, IntegerPKMixin, ActiveRecord): """Model for Pydantic native validation contract tests.""" - __table_name__ = "pydantic_validated_models" + @classmethod + def table_name(cls): + return "pydantic_validated_models" id: Optional[int] = None class AsyncPydanticValidatedModel(PydanticValidatedFieldsMixin, IntegerPKMixin, AsyncActiveRecord): """Async model for Pydantic native validation contract tests.""" - __table_name__ = "pydantic_validated_models" + @classmethod + def table_name(cls): + return "pydantic_validated_models" id: Optional[int] = None @@ -312,7 +343,9 @@ def _do_from_database(self, value: str, target_type: Type, options: Optional[Dic class TypeAdapterTest(ActiveRecord): """Model for testing various type adapter scenarios.""" - __table_name__ = 'type_adapter_tests' + @classmethod + def table_name(cls): + return 'type_adapter_tests' id: Optional[int] = None name: str @@ -329,7 +362,10 @@ class TypeAdapterTest(ActiveRecord): class AsyncTypeAdapterTest(AsyncActiveRecord): """Model for testing various type adapter scenarios.""" - __table_name__ = 'type_adapter_tests' + @classmethod + def table_name(cls): + return 'type_adapter_tests' + c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -350,7 +386,9 @@ class AsyncTypeAdapterTest(AsyncActiveRecord): class MappedUser(IntegerPKMixin, TimestampMixin, ActiveRecord): """User model with custom column name mappings for testing in basic feature.""" - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" # Python field: user_id, Database column: id user_id: Annotated[Optional[int], UseColumn("id")] = None @@ -368,7 +406,10 @@ class MappedUser(IntegerPKMixin, TimestampMixin, ActiveRecord): class AsyncMappedUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """User model with custom column name mappings for testing in basic feature.""" - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + c: ClassVar[FieldProxy] = FieldProxy() # Python field: user_id, Database column: id @@ -388,7 +429,9 @@ class AsyncMappedUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): class MappedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): """Post model with custom column name mappings for testing in basic feature.""" - __table_name__ = "posts" + @classmethod + def table_name(cls): + return "posts" # Python field: post_id, Database column: id post_id: Annotated[Optional[int], UseColumn("id")] = None @@ -411,7 +454,10 @@ class MappedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): class AsyncMappedPost(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """Post model with custom column name mappings for testing in basic feature.""" - __table_name__ = "posts" + @classmethod + def table_name(cls): + return "posts" + c: ClassVar[FieldProxy] = FieldProxy() # Python field: post_id, Database column: id @@ -436,7 +482,9 @@ class AsyncMappedPost(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): class MappedComment(IntegerPKMixin, TimestampMixin, ActiveRecord): """Comment model with custom column name mappings for testing in basic feature.""" - __table_name__ = "comments" + @classmethod + def table_name(cls): + return "comments" # Python field: comment_id, Database column: id comment_id: Annotated[Optional[int], UseColumn("id")] = None @@ -460,7 +508,10 @@ class MappedComment(IntegerPKMixin, TimestampMixin, ActiveRecord): class AsyncMappedComment(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """Comment model with custom column name mappings for testing in basic feature.""" - __table_name__ = "comments" + @classmethod + def table_name(cls): + return "comments" + c: ClassVar[FieldProxy] = FieldProxy() # Python field: comment_id, Database column: id @@ -504,7 +555,9 @@ class ColumnMappingModel(ActiveRecord): A model demonstrating various valid uses of UseColumn and UseAdapter, including mapping the primary key attribute. """ - __table_name__ = 'column_mapping_items' + @classmethod + def table_name(cls): + return 'column_mapping_items' # Primary key defaults to 'id'. item_id attribute maps to this 'id' column. item_id: Annotated[Optional[int], UseColumn("id")] = Field(default=None) @@ -517,7 +570,10 @@ class AsyncColumnMappingModel(AsyncActiveRecord): A model demonstrating various valid uses of UseColumn and UseAdapter, including mapping the primary key attribute. """ - __table_name__ = 'column_mapping_items' + @classmethod + def table_name(cls): + return 'column_mapping_items' + c: ClassVar[FieldProxy] = FieldProxy() # Primary key defaults to 'id'. item_id attribute maps to this 'id' column. @@ -603,7 +659,9 @@ def _do_from_database(self, value: str, target_type: Type, options: Optional[Dic class MixedAnnotationModel(ActiveRecord): """A model with various combinations of annotations to test field mapping and adapters.""" - __table_name__ = "mixed_annotation_items" + @classmethod + def table_name(cls): + return "mixed_annotation_items" # 1. Standard Python type name: str @@ -626,7 +684,10 @@ class MixedAnnotationModel(ActiveRecord): class AsyncMixedAnnotationModel(AsyncActiveRecord): """A model with various combinations of annotations to test field mapping and adapters.""" - __table_name__ = "mixed_annotation_items" + @classmethod + def table_name(cls): + return "mixed_annotation_items" + c: ClassVar[FieldProxy] = FieldProxy() # 1. Standard Python type @@ -650,7 +711,10 @@ class AsyncMixedAnnotationModel(AsyncActiveRecord): class BulkUser(IntegerPKMixin, ActiveRecord): - __table_name__ = "bulk_users" + @classmethod + def table_name(cls): + return "bulk_users" + c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -660,7 +724,10 @@ class BulkUser(IntegerPKMixin, ActiveRecord): class AsyncBulkUser(IntegerPKMixin, AsyncActiveRecord): - __table_name__ = "bulk_users" + @classmethod + def table_name(cls): + return "bulk_users" + c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -675,8 +742,14 @@ class AsyncBulkUser(IntegerPKMixin, AsyncActiveRecord): class OrderItem(CompositePKMixin, ActiveRecord): """Composite PK (order_id, product_id), PK provided by application.""" - __table_name__ = "order_items" - __primary_key__ = ("order_id", "product_id") + @classmethod + def table_name(cls): + return "order_items" + + @classmethod + def primary_key(cls): + return ("order_id", "product_id") + c: ClassVar[FieldProxy] = FieldProxy() order_id: int @@ -687,8 +760,14 @@ class OrderItem(CompositePKMixin, ActiveRecord): class AsyncOrderItem(CompositePKMixin, AsyncActiveRecord): """Async variant of OrderItem.""" - __table_name__ = "order_items" - __primary_key__ = ("order_id", "product_id") + @classmethod + def table_name(cls): + return "order_items" + + @classmethod + def primary_key(cls): + return ("order_id", "product_id") + c: ClassVar[FieldProxy] = FieldProxy() order_id: int @@ -699,8 +778,14 @@ class AsyncOrderItem(CompositePKMixin, AsyncActiveRecord): class StoreInventory(CompositePKMixin, ActiveRecord): """Triple-column composite PK.""" - __table_name__ = "store_inventory" - __primary_key__ = ("store_id", "product_id", "batch_id") + @classmethod + def table_name(cls): + return "store_inventory" + + @classmethod + def primary_key(cls): + return ("store_id", "product_id", "batch_id") + c: ClassVar[FieldProxy] = FieldProxy() store_id: int @@ -711,8 +796,14 @@ class StoreInventory(CompositePKMixin, ActiveRecord): class AsyncStoreInventory(CompositePKMixin, AsyncActiveRecord): """Async variant of StoreInventory.""" - __table_name__ = "store_inventory" - __primary_key__ = ("store_id", "product_id", "batch_id") + @classmethod + def table_name(cls): + return "store_inventory" + + @classmethod + def primary_key(cls): + return ("store_id", "product_id", "batch_id") + c: ClassVar[FieldProxy] = FieldProxy() store_id: int @@ -723,7 +814,10 @@ class AsyncStoreInventory(CompositePKMixin, AsyncActiveRecord): class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): """Single-column auto-increment PK — backward compatibility control group.""" - __table_name__ = "orders" + @classmethod + def table_name(cls): + return "orders" + c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -732,7 +826,10 @@ class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): class AsyncOrder(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """Async variant of Order.""" - __table_name__ = "orders" + @classmethod + def table_name(cls): + return "orders" + c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -741,8 +838,13 @@ class AsyncOrder(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): class MappedOrderItem(CompositePKMixin, ActiveRecord): """Composite PK with UseColumn mapping — Python field names differ from DB column names.""" - __table_name__ = "order_items" - __primary_key__ = ("order_id", "product_id") + @classmethod + def table_name(cls): + return "order_items" + + @classmethod + def primary_key(cls): + return ("order_id", "product_id") order_ref: Annotated[int, UseColumn("order_id")] product_ref: Annotated[int, UseColumn("product_id")] @@ -752,8 +854,14 @@ class MappedOrderItem(CompositePKMixin, ActiveRecord): class AsyncMappedOrderItem(CompositePKMixin, AsyncActiveRecord): """Async variant of MappedOrderItem.""" - __table_name__ = "order_items" - __primary_key__ = ("order_id", "product_id") + @classmethod + def table_name(cls): + return "order_items" + + @classmethod + def primary_key(cls): + return ("order_id", "product_id") + c: ClassVar[FieldProxy] = FieldProxy() order_ref: Annotated[int, UseColumn("order_id")] @@ -771,7 +879,9 @@ class AsyncMappedOrderItem(CompositePKMixin, AsyncActiveRecord): class Product(ActiveRecord): - __table_name__ = "product" + @classmethod + def table_name(cls): + return "product" id: Optional[int] = None name: str @@ -788,7 +898,9 @@ class Product(ActiveRecord): class AsyncProduct(AsyncActiveRecord): - __table_name__ = "product" + @classmethod + def table_name(cls): + return "product" id: Optional[int] = None name: str @@ -806,7 +918,9 @@ class AsyncProduct(AsyncActiveRecord): class ProductFormA(ActiveRecord): """Form A declaration: ClassVar[DerivedField] = DerivedField(...).""" - __table_name__ = "product" + @classmethod + def table_name(cls): + return "product" id: Optional[int] = None name: str @@ -823,7 +937,9 @@ class ProductFormA(ActiveRecord): class AsyncProductFormA(AsyncActiveRecord): - __table_name__ = "product" + @classmethod + def table_name(cls): + return "product" id: Optional[int] = None name: str @@ -855,7 +971,9 @@ def supported_types(self) -> Dict[Type, Set[Type]]: class ProductWithProxy(ActiveRecord): """Product model using FieldProxy in DerivedField expressions.""" - __table_name__ = "product" + @classmethod + def table_name(cls): + return "product" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -873,7 +991,9 @@ class ProductWithProxy(ActiveRecord): class AsyncProductWithProxy(AsyncActiveRecord): - __table_name__ = "product" + @classmethod + def table_name(cls): + return "product" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -892,7 +1012,9 @@ class AsyncProductWithProxy(AsyncActiveRecord): class ProductWithColumnAndAdapter(ActiveRecord): """Product model with UseColumn and UseAdapter on derived fields.""" - __table_name__ = "product" + @classmethod + def table_name(cls): + return "product" id: Optional[int] = None name: str @@ -909,7 +1031,9 @@ class ProductWithColumnAndAdapter(ActiveRecord): class AsyncProductWithColumnAndAdapter(AsyncActiveRecord): - __table_name__ = "product" + @classmethod + def table_name(cls): + return "product" id: Optional[int] = None name: str diff --git a/tests/rhosocial/activerecord_test/feature/basic/fixtures/models_py310.py b/tests/rhosocial/activerecord_test/feature/basic/fixtures/models_py310.py index 17e011263..6768e14db 100644 --- a/tests/rhosocial/activerecord_test/feature/basic/fixtures/models_py310.py +++ b/tests/rhosocial/activerecord_test/feature/basic/fixtures/models_py310.py @@ -34,7 +34,9 @@ class TypeCase(UUIDMixin, ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ - __table_name__ = "type_cases" + @classmethod + def table_name(cls): + return "type_cases" username: str email: str @@ -61,7 +63,10 @@ class AsyncTypeCase(UUIDMixin, AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ - __table_name__ = "type_cases" + @classmethod + def table_name(cls): + return "type_cases" + c: ClassVar[FieldProxy] = FieldProxy() username: str @@ -89,7 +94,9 @@ class TypeTestModel(UUIDMixin, ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ - __table_name__ = "type_tests" + @classmethod + def table_name(cls): + return "type_tests" # UUID primary key provided by UUIDMixin string_field: str = Field(default="test string") @@ -107,7 +114,10 @@ class AsyncTypeTestModel(UUIDMixin, AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ - __table_name__ = "type_tests" + @classmethod + def table_name(cls): + return "type_tests" + c: ClassVar[FieldProxy] = FieldProxy() # UUID primary key provided by UUIDMixin @@ -126,7 +136,10 @@ class User(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + c: ClassVar[FieldProxy] = FieldProxy() # The IntegerPKMixin is expected to handle the `id` field. @@ -143,7 +156,10 @@ class AsyncUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + c: ClassVar[FieldProxy] = FieldProxy() # The IntegerPKMixin is expected to handle the `id` field. @@ -161,7 +177,10 @@ class ValidatedFieldUser(IntegerPKMixin, ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ - __table_name__ = "validated_field_users" + @classmethod + def table_name(cls): + return "validated_field_users" + c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -194,7 +213,10 @@ class AsyncValidatedFieldUser(IntegerPKMixin, AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ - __table_name__ = "validated_field_users" + @classmethod + def table_name(cls): + return "validated_field_users" + c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -226,7 +248,9 @@ class ValidatedUser(IntegerPKMixin, ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ - __table_name__ = "validated_users" + @classmethod + def table_name(cls): + return "validated_users" id: int | None = None username: str = Field(..., min_length=3, max_length=50) @@ -253,7 +277,10 @@ class AsyncValidatedUser(IntegerPKMixin, AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ - __table_name__ = "validated_users" + @classmethod + def table_name(cls): + return "validated_users" + c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -311,14 +338,18 @@ def validate_period(self): class PydanticValidatedModel(PydanticValidatedFieldsMixin, IntegerPKMixin, ActiveRecord): """Model for Pydantic native validation contract tests.""" - __table_name__ = "pydantic_validated_models" + @classmethod + def table_name(cls): + return "pydantic_validated_models" id: int | None = None class AsyncPydanticValidatedModel(PydanticValidatedFieldsMixin, IntegerPKMixin, AsyncActiveRecord): """Async model for Pydantic native validation contract tests.""" - __table_name__ = "pydantic_validated_models" + @classmethod + def table_name(cls): + return "pydantic_validated_models" id: int | None = None @@ -337,7 +368,9 @@ class TypeAdapterTest(ActiveRecord): Python 3.10+ version using | syntax instead of Optional and Union. """ - __table_name__ = 'type_adapter_tests' + @classmethod + def table_name(cls): + return 'type_adapter_tests' id: int | None = None name: str @@ -358,7 +391,10 @@ class AsyncTypeAdapterTest(AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional and Union. """ - __table_name__ = 'type_adapter_tests' + @classmethod + def table_name(cls): + return 'type_adapter_tests' + c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -380,7 +416,9 @@ class MappedUser(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" # Python field: user_id, Database column: id user_id: Annotated[int | None, UseColumn("id")] = None @@ -400,7 +438,10 @@ class AsyncMappedUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + c: ClassVar[FieldProxy] = FieldProxy() # Python field: user_id, Database column: id @@ -421,7 +462,9 @@ class MappedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ - __table_name__ = "posts" + @classmethod + def table_name(cls): + return "posts" # Python field: post_id, Database column: id post_id: Annotated[int | None, UseColumn("id")] = None @@ -447,7 +490,10 @@ class AsyncMappedPost(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ - __table_name__ = "posts" + @classmethod + def table_name(cls): + return "posts" + c: ClassVar[FieldProxy] = FieldProxy() # Python field: post_id, Database column: id @@ -474,7 +520,9 @@ class MappedComment(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ - __table_name__ = "comments" + @classmethod + def table_name(cls): + return "comments" # Python field: comment_id, Database column: id comment_id: Annotated[int | None, UseColumn("id")] = None @@ -500,7 +548,10 @@ class AsyncMappedComment(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ - __table_name__ = "comments" + @classmethod + def table_name(cls): + return "comments" + c: ClassVar[FieldProxy] = FieldProxy() # Python field: comment_id, Database column: id @@ -542,7 +593,9 @@ class ColumnMappingModel(ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ - __table_name__ = 'column_mapping_items' + @classmethod + def table_name(cls): + return 'column_mapping_items' # Primary key defaults to 'id'. item_id attribute maps to this 'id' column. item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) @@ -557,7 +610,10 @@ class AsyncColumnMappingModel(AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ - __table_name__ = 'column_mapping_items' + @classmethod + def table_name(cls): + return 'column_mapping_items' + c: ClassVar[FieldProxy] = FieldProxy() # Primary key defaults to 'id'. item_id attribute maps to this 'id' column. @@ -636,7 +692,9 @@ class MixedAnnotationModel(ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ - __table_name__ = "mixed_annotation_items" + @classmethod + def table_name(cls): + return "mixed_annotation_items" # 1. Standard Python type name: str @@ -662,7 +720,10 @@ class AsyncMixedAnnotationModel(AsyncActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ - __table_name__ = "mixed_annotation_items" + @classmethod + def table_name(cls): + return "mixed_annotation_items" + c: ClassVar[FieldProxy] = FieldProxy() # 1. Standard Python type @@ -689,8 +750,14 @@ class AsyncMixedAnnotationModel(AsyncActiveRecord): class OrderItem(CompositePKMixin, ActiveRecord): """Composite PK (order_id, product_id), PK provided by application.""" - __table_name__ = "order_items" - __primary_key__ = ("order_id", "product_id") + @classmethod + def table_name(cls): + return "order_items" + + @classmethod + def primary_key(cls): + return ("order_id", "product_id") + c: ClassVar[FieldProxy] = FieldProxy() order_id: int @@ -701,8 +768,14 @@ class OrderItem(CompositePKMixin, ActiveRecord): class AsyncOrderItem(CompositePKMixin, AsyncActiveRecord): """Async variant of OrderItem.""" - __table_name__ = "order_items" - __primary_key__ = ("order_id", "product_id") + @classmethod + def table_name(cls): + return "order_items" + + @classmethod + def primary_key(cls): + return ("order_id", "product_id") + c: ClassVar[FieldProxy] = FieldProxy() order_id: int @@ -713,8 +786,14 @@ class AsyncOrderItem(CompositePKMixin, AsyncActiveRecord): class StoreInventory(CompositePKMixin, ActiveRecord): """Triple-column composite PK.""" - __table_name__ = "store_inventory" - __primary_key__ = ("store_id", "product_id", "batch_id") + @classmethod + def table_name(cls): + return "store_inventory" + + @classmethod + def primary_key(cls): + return ("store_id", "product_id", "batch_id") + c: ClassVar[FieldProxy] = FieldProxy() store_id: int @@ -725,8 +804,14 @@ class StoreInventory(CompositePKMixin, ActiveRecord): class AsyncStoreInventory(CompositePKMixin, AsyncActiveRecord): """Async variant of StoreInventory.""" - __table_name__ = "store_inventory" - __primary_key__ = ("store_id", "product_id", "batch_id") + @classmethod + def table_name(cls): + return "store_inventory" + + @classmethod + def primary_key(cls): + return ("store_id", "product_id", "batch_id") + c: ClassVar[FieldProxy] = FieldProxy() store_id: int @@ -737,7 +822,10 @@ class AsyncStoreInventory(CompositePKMixin, AsyncActiveRecord): class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): """Single-column auto-increment PK -- backward compatibility control group.""" - __table_name__ = "orders" + @classmethod + def table_name(cls): + return "orders" + c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -746,7 +834,10 @@ class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): class AsyncOrder(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """Async variant of Order.""" - __table_name__ = "orders" + @classmethod + def table_name(cls): + return "orders" + c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -755,8 +846,13 @@ class AsyncOrder(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): class MappedOrderItem(CompositePKMixin, ActiveRecord): """Composite PK with UseColumn mapping -- Python field names differ from DB column names.""" - __table_name__ = "order_items" - __primary_key__ = ("order_id", "product_id") + @classmethod + def table_name(cls): + return "order_items" + + @classmethod + def primary_key(cls): + return ("order_id", "product_id") order_ref: Annotated[int, UseColumn("order_id")] product_ref: Annotated[int, UseColumn("product_id")] @@ -766,8 +862,14 @@ class MappedOrderItem(CompositePKMixin, ActiveRecord): class AsyncMappedOrderItem(CompositePKMixin, AsyncActiveRecord): """Async variant of MappedOrderItem.""" - __table_name__ = "order_items" - __primary_key__ = ("order_id", "product_id") + @classmethod + def table_name(cls): + return "order_items" + + @classmethod + def primary_key(cls): + return ("order_id", "product_id") + c: ClassVar[FieldProxy] = FieldProxy() order_ref: Annotated[int, UseColumn("order_id")] @@ -777,7 +879,10 @@ class AsyncMappedOrderItem(CompositePKMixin, AsyncActiveRecord): class BulkUser(IntegerPKMixin, ActiveRecord): - __table_name__ = "bulk_users" + @classmethod + def table_name(cls): + return "bulk_users" + c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -787,7 +892,10 @@ class BulkUser(IntegerPKMixin, ActiveRecord): class AsyncBulkUser(IntegerPKMixin, AsyncActiveRecord): - __table_name__ = "bulk_users" + @classmethod + def table_name(cls): + return "bulk_users" + c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None diff --git a/tests/rhosocial/activerecord_test/feature/basic/fixtures/models_py311.py b/tests/rhosocial/activerecord_test/feature/basic/fixtures/models_py311.py index 8486a795f..a2c1e092a 100644 --- a/tests/rhosocial/activerecord_test/feature/basic/fixtures/models_py311.py +++ b/tests/rhosocial/activerecord_test/feature/basic/fixtures/models_py311.py @@ -38,7 +38,9 @@ class TypeCase(UUIDMixin, ActiveRecord): Python 3.11+ version using | syntax and Self type. """ - __table_name__ = "type_cases" + @classmethod + def table_name(cls): + return "type_cases" username: str email: str @@ -72,7 +74,10 @@ class AsyncTypeCase(UUIDMixin, AsyncActiveRecord): Python 3.11+ version using | syntax and Self type. """ - __table_name__ = "type_cases" + @classmethod + def table_name(cls): + return "type_cases" + c: ClassVar[FieldProxy] = FieldProxy() username: str @@ -107,7 +112,9 @@ class TypeTestModel(UUIDMixin, ActiveRecord): Python 3.11+ version using | syntax and Self type. """ - __table_name__ = "type_tests" + @classmethod + def table_name(cls): + return "type_tests" # UUID primary key provided by UUIDMixin string_field: str = Field(default="test string") @@ -125,7 +132,10 @@ class AsyncTypeTestModel(UUIDMixin, AsyncActiveRecord): Python 3.11+ version using | syntax and Self type. """ - __table_name__ = "type_tests" + @classmethod + def table_name(cls): + return "type_tests" + c: ClassVar[FieldProxy] = FieldProxy() # UUID primary key provided by UUIDMixin @@ -144,7 +154,10 @@ class User(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.11+ version using | syntax and Self type. """ - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -185,7 +198,10 @@ class AsyncUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.11+ version using | syntax and Self type. """ - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -208,7 +224,10 @@ class ValidatedFieldUser(IntegerPKMixin, ActiveRecord): Python 3.11+ version using | syntax and Self type. """ - __table_name__ = "validated_field_users" + @classmethod + def table_name(cls): + return "validated_field_users" + c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -241,7 +260,10 @@ class AsyncValidatedFieldUser(IntegerPKMixin, AsyncActiveRecord): Python 3.11+ version using | syntax and Self type. """ - __table_name__ = "validated_field_users" + @classmethod + def table_name(cls): + return "validated_field_users" + c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -273,7 +295,9 @@ class ValidatedUser(IntegerPKMixin, ActiveRecord): Python 3.11+ version using | syntax and Self type. """ - __table_name__ = "validated_users" + @classmethod + def table_name(cls): + return "validated_users" id: int | None = None username: str = Field(..., min_length=3, max_length=50) @@ -300,7 +324,10 @@ class AsyncValidatedUser(IntegerPKMixin, AsyncActiveRecord): Python 3.11+ version using | syntax and Self type. """ - __table_name__ = "validated_users" + @classmethod + def table_name(cls): + return "validated_users" + c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -358,14 +385,18 @@ def validate_period(self): class PydanticValidatedModel(PydanticValidatedFieldsMixin, IntegerPKMixin, ActiveRecord): """Model for Pydantic native validation contract tests.""" - __table_name__ = "pydantic_validated_models" + @classmethod + def table_name(cls): + return "pydantic_validated_models" id: int | None = None class AsyncPydanticValidatedModel(PydanticValidatedFieldsMixin, IntegerPKMixin, AsyncActiveRecord): """Async model for Pydantic native validation contract tests.""" - __table_name__ = "pydantic_validated_models" + @classmethod + def table_name(cls): + return "pydantic_validated_models" id: int | None = None @@ -384,7 +415,9 @@ class TypeAdapterTest(ActiveRecord): Python 3.11+ version using | syntax. """ - __table_name__ = 'type_adapter_tests' + @classmethod + def table_name(cls): + return 'type_adapter_tests' id: int | None = None name: str @@ -402,7 +435,10 @@ class AsyncTypeAdapterTest(AsyncActiveRecord): Python 3.11+ version using | syntax. """ - __table_name__ = 'type_adapter_tests' + @classmethod + def table_name(cls): + return 'type_adapter_tests' + c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -421,7 +457,9 @@ class MappedUser(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.11+ version using | syntax and Self type. """ - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" user_id: Annotated[int | None, UseColumn("id")] = None user_name: Annotated[str, UseColumn("username")] @@ -439,7 +477,10 @@ class AsyncMappedUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.11+ version using | syntax. """ - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + c: ClassVar[FieldProxy] = FieldProxy() user_id: Annotated[int | None, UseColumn("id")] = None @@ -453,7 +494,9 @@ class MappedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.11+ version using | syntax. """ - __table_name__ = "posts" + @classmethod + def table_name(cls): + return "posts" post_id: Annotated[int | None, UseColumn("id")] = None author_id: Annotated[int, UseColumn("author")] @@ -468,7 +511,10 @@ class AsyncMappedPost(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.11+ version using | syntax. """ - __table_name__ = "posts" + @classmethod + def table_name(cls): + return "posts" + c: ClassVar[FieldProxy] = FieldProxy() post_id: Annotated[int | None, UseColumn("id")] = None @@ -484,7 +530,9 @@ class MappedComment(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.11+ version using | syntax. """ - __table_name__ = "comments" + @classmethod + def table_name(cls): + return "comments" comment_id: Annotated[int | None, UseColumn("id")] = None post_id: Annotated[int, UseColumn("post_ref")] @@ -499,7 +547,10 @@ class AsyncMappedComment(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.11+ version using | syntax. """ - __table_name__ = "comments" + @classmethod + def table_name(cls): + return "comments" + c: ClassVar[FieldProxy] = FieldProxy() comment_id: Annotated[int | None, UseColumn("id")] = None @@ -529,7 +580,9 @@ class ColumnMappingModel(ActiveRecord): Python 3.11+ version using | syntax. """ - __table_name__ = 'column_mapping_items' + @classmethod + def table_name(cls): + return 'column_mapping_items' item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) name: str @@ -542,7 +595,10 @@ class AsyncColumnMappingModel(AsyncActiveRecord): Python 3.11+ version using | syntax. """ - __table_name__ = 'column_mapping_items' + @classmethod + def table_name(cls): + return 'column_mapping_items' + c: ClassVar[FieldProxy] = FieldProxy() item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) @@ -620,7 +676,9 @@ class MixedAnnotationModel(ActiveRecord): Python 3.11+ version using | syntax. """ - __table_name__ = "mixed_annotation_items" + @classmethod + def table_name(cls): + return "mixed_annotation_items" name: str item_id: Annotated[int, UseColumn("id")] @@ -644,7 +702,10 @@ class AsyncMixedAnnotationModel(AsyncActiveRecord): Python 3.11+ version using | syntax. """ - __table_name__ = "mixed_annotation_items" + @classmethod + def table_name(cls): + return "mixed_annotation_items" + c: ClassVar[FieldProxy] = FieldProxy() name: str @@ -660,8 +721,14 @@ class AsyncMixedAnnotationModel(AsyncActiveRecord): class OrderItem(CompositePKMixin, ActiveRecord): """Composite PK (order_id, product_id), PK provided by application.""" - __table_name__ = "order_items" - __primary_key__ = ("order_id", "product_id") + @classmethod + def table_name(cls): + return "order_items" + + @classmethod + def primary_key(cls): + return ("order_id", "product_id") + c: ClassVar[FieldProxy] = FieldProxy() order_id: int @@ -672,8 +739,14 @@ class OrderItem(CompositePKMixin, ActiveRecord): class AsyncOrderItem(CompositePKMixin, AsyncActiveRecord): """Async variant of OrderItem.""" - __table_name__ = "order_items" - __primary_key__ = ("order_id", "product_id") + @classmethod + def table_name(cls): + return "order_items" + + @classmethod + def primary_key(cls): + return ("order_id", "product_id") + c: ClassVar[FieldProxy] = FieldProxy() order_id: int @@ -684,8 +757,14 @@ class AsyncOrderItem(CompositePKMixin, AsyncActiveRecord): class StoreInventory(CompositePKMixin, ActiveRecord): """Triple-column composite PK.""" - __table_name__ = "store_inventory" - __primary_key__ = ("store_id", "product_id", "batch_id") + @classmethod + def table_name(cls): + return "store_inventory" + + @classmethod + def primary_key(cls): + return ("store_id", "product_id", "batch_id") + c: ClassVar[FieldProxy] = FieldProxy() store_id: int @@ -696,8 +775,14 @@ class StoreInventory(CompositePKMixin, ActiveRecord): class AsyncStoreInventory(CompositePKMixin, AsyncActiveRecord): """Async variant of StoreInventory.""" - __table_name__ = "store_inventory" - __primary_key__ = ("store_id", "product_id", "batch_id") + @classmethod + def table_name(cls): + return "store_inventory" + + @classmethod + def primary_key(cls): + return ("store_id", "product_id", "batch_id") + c: ClassVar[FieldProxy] = FieldProxy() store_id: int @@ -708,7 +793,10 @@ class AsyncStoreInventory(CompositePKMixin, AsyncActiveRecord): class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): """Single-column auto-increment PK -- backward compatibility control group.""" - __table_name__ = "orders" + @classmethod + def table_name(cls): + return "orders" + c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -717,7 +805,10 @@ class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): class AsyncOrder(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """Async variant of Order.""" - __table_name__ = "orders" + @classmethod + def table_name(cls): + return "orders" + c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -726,8 +817,13 @@ class AsyncOrder(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): class MappedOrderItem(CompositePKMixin, ActiveRecord): """Composite PK with UseColumn mapping -- Python field names differ from DB column names.""" - __table_name__ = "order_items" - __primary_key__ = ("order_id", "product_id") + @classmethod + def table_name(cls): + return "order_items" + + @classmethod + def primary_key(cls): + return ("order_id", "product_id") order_ref: Annotated[int, UseColumn("order_id")] product_ref: Annotated[int, UseColumn("product_id")] @@ -737,8 +833,14 @@ class MappedOrderItem(CompositePKMixin, ActiveRecord): class AsyncMappedOrderItem(CompositePKMixin, AsyncActiveRecord): """Async variant of MappedOrderItem.""" - __table_name__ = "order_items" - __primary_key__ = ("order_id", "product_id") + @classmethod + def table_name(cls): + return "order_items" + + @classmethod + def primary_key(cls): + return ("order_id", "product_id") + c: ClassVar[FieldProxy] = FieldProxy() order_ref: Annotated[int, UseColumn("order_id")] @@ -748,7 +850,10 @@ class AsyncMappedOrderItem(CompositePKMixin, AsyncActiveRecord): class BulkUser(IntegerPKMixin, ActiveRecord): - __table_name__ = "bulk_users" + @classmethod + def table_name(cls): + return "bulk_users" + c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -758,7 +863,10 @@ class BulkUser(IntegerPKMixin, ActiveRecord): class AsyncBulkUser(IntegerPKMixin, AsyncActiveRecord): - __table_name__ = "bulk_users" + @classmethod + def table_name(cls): + return "bulk_users" + c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None diff --git a/tests/rhosocial/activerecord_test/feature/basic/fixtures/models_py312.py b/tests/rhosocial/activerecord_test/feature/basic/fixtures/models_py312.py index 6486914f8..ec71b7e1a 100644 --- a/tests/rhosocial/activerecord_test/feature/basic/fixtures/models_py312.py +++ b/tests/rhosocial/activerecord_test/feature/basic/fixtures/models_py312.py @@ -38,7 +38,9 @@ class TypeCase(UUIDMixin, ActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ - __table_name__ = "type_cases" + @classmethod + def table_name(cls): + return "type_cases" username: str email: str @@ -69,7 +71,10 @@ class AsyncTypeCase(UUIDMixin, AsyncActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ - __table_name__ = "type_cases" + @classmethod + def table_name(cls): + return "type_cases" + c: ClassVar[FieldProxy] = FieldProxy() username: str @@ -101,7 +106,9 @@ class TypeTestModel(UUIDMixin, ActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ - __table_name__ = "type_tests" + @classmethod + def table_name(cls): + return "type_tests" # UUID primary key provided by UUIDMixin string_field: str = Field(default="test string") @@ -119,7 +126,10 @@ class AsyncTypeTestModel(UUIDMixin, AsyncActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ - __table_name__ = "type_tests" + @classmethod + def table_name(cls): + return "type_tests" + c: ClassVar[FieldProxy] = FieldProxy() # UUID primary key provided by UUIDMixin @@ -138,7 +148,10 @@ class User(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -170,7 +183,10 @@ class AsyncUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -193,7 +209,10 @@ class ValidatedFieldUser(IntegerPKMixin, ActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ - __table_name__ = "validated_field_users" + @classmethod + def table_name(cls): + return "validated_field_users" + c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -226,7 +245,10 @@ class AsyncValidatedFieldUser(IntegerPKMixin, AsyncActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ - __table_name__ = "validated_field_users" + @classmethod + def table_name(cls): + return "validated_field_users" + c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -258,7 +280,9 @@ class ValidatedUser(IntegerPKMixin, ActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ - __table_name__ = "validated_users" + @classmethod + def table_name(cls): + return "validated_users" id: int | None = None username: str = Field(..., min_length=3, max_length=50) @@ -286,7 +310,10 @@ class AsyncValidatedUser(IntegerPKMixin, AsyncActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ - __table_name__ = "validated_users" + @classmethod + def table_name(cls): + return "validated_users" + c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -345,14 +372,18 @@ def validate_period(self): class PydanticValidatedModel(PydanticValidatedFieldsMixin, IntegerPKMixin, ActiveRecord): """Model for Pydantic native validation contract tests.""" - __table_name__ = "pydantic_validated_models" + @classmethod + def table_name(cls): + return "pydantic_validated_models" id: int | None = None class AsyncPydanticValidatedModel(PydanticValidatedFieldsMixin, IntegerPKMixin, AsyncActiveRecord): """Async model for Pydantic native validation contract tests.""" - __table_name__ = "pydantic_validated_models" + @classmethod + def table_name(cls): + return "pydantic_validated_models" id: int | None = None @@ -371,7 +402,9 @@ class TypeAdapterTest(ActiveRecord): Python 3.12+ version using | syntax. """ - __table_name__ = 'type_adapter_tests' + @classmethod + def table_name(cls): + return 'type_adapter_tests' id: int | None = None name: str @@ -389,7 +422,10 @@ class AsyncTypeAdapterTest(AsyncActiveRecord): Python 3.12+ version using | syntax. """ - __table_name__ = 'type_adapter_tests' + @classmethod + def table_name(cls): + return 'type_adapter_tests' + c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -408,7 +444,9 @@ class MappedUser(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" user_id: Annotated[int | None, UseColumn("id")] = None user_name: Annotated[str, UseColumn("username")] @@ -426,7 +464,10 @@ class AsyncMappedUser(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.12+ version using | syntax. """ - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + c: ClassVar[FieldProxy] = FieldProxy() user_id: Annotated[int | None, UseColumn("id")] = None @@ -440,7 +481,9 @@ class MappedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.12+ version using | syntax. """ - __table_name__ = "posts" + @classmethod + def table_name(cls): + return "posts" post_id: Annotated[int | None, UseColumn("id")] = None author_id: Annotated[int, UseColumn("author")] @@ -455,7 +498,10 @@ class AsyncMappedPost(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.12+ version using | syntax. """ - __table_name__ = "posts" + @classmethod + def table_name(cls): + return "posts" + c: ClassVar[FieldProxy] = FieldProxy() post_id: Annotated[int | None, UseColumn("id")] = None @@ -471,7 +517,9 @@ class MappedComment(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.12+ version using | syntax. """ - __table_name__ = "comments" + @classmethod + def table_name(cls): + return "comments" comment_id: Annotated[int | None, UseColumn("id")] = None post_id: Annotated[int, UseColumn("post_ref")] @@ -486,7 +534,10 @@ class AsyncMappedComment(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): Python 3.12+ version using | syntax. """ - __table_name__ = "comments" + @classmethod + def table_name(cls): + return "comments" + c: ClassVar[FieldProxy] = FieldProxy() comment_id: Annotated[int | None, UseColumn("id")] = None @@ -516,7 +567,9 @@ class ColumnMappingModel(ActiveRecord): Python 3.12+ version using | syntax. """ - __table_name__ = 'column_mapping_items' + @classmethod + def table_name(cls): + return 'column_mapping_items' item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) name: str @@ -529,7 +582,10 @@ class AsyncColumnMappingModel(AsyncActiveRecord): Python 3.12+ version using | syntax. """ - __table_name__ = 'column_mapping_items' + @classmethod + def table_name(cls): + return 'column_mapping_items' + c: ClassVar[FieldProxy] = FieldProxy() item_id: Annotated[int | None, UseColumn("id")] = Field(default=None) @@ -607,7 +663,9 @@ class MixedAnnotationModel(ActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ - __table_name__ = "mixed_annotation_items" + @classmethod + def table_name(cls): + return "mixed_annotation_items" name: str item_id: Annotated[int, UseColumn("id")] @@ -628,7 +686,10 @@ class AsyncMixedAnnotationModel(AsyncActiveRecord): Python 3.12+ version using | syntax. """ - __table_name__ = "mixed_annotation_items" + @classmethod + def table_name(cls): + return "mixed_annotation_items" + c: ClassVar[FieldProxy] = FieldProxy() name: str @@ -694,8 +755,14 @@ def unwrap(self) -> T: class OrderItem(CompositePKMixin, ActiveRecord): """Composite PK (order_id, product_id), PK provided by application.""" - __table_name__ = "order_items" - __primary_key__ = ("order_id", "product_id") + @classmethod + def table_name(cls): + return "order_items" + + @classmethod + def primary_key(cls): + return ("order_id", "product_id") + c: ClassVar[FieldProxy] = FieldProxy() order_id: int @@ -706,8 +773,14 @@ class OrderItem(CompositePKMixin, ActiveRecord): class AsyncOrderItem(CompositePKMixin, AsyncActiveRecord): """Async variant of OrderItem.""" - __table_name__ = "order_items" - __primary_key__ = ("order_id", "product_id") + @classmethod + def table_name(cls): + return "order_items" + + @classmethod + def primary_key(cls): + return ("order_id", "product_id") + c: ClassVar[FieldProxy] = FieldProxy() order_id: int @@ -718,8 +791,14 @@ class AsyncOrderItem(CompositePKMixin, AsyncActiveRecord): class StoreInventory(CompositePKMixin, ActiveRecord): """Triple-column composite PK.""" - __table_name__ = "store_inventory" - __primary_key__ = ("store_id", "product_id", "batch_id") + @classmethod + def table_name(cls): + return "store_inventory" + + @classmethod + def primary_key(cls): + return ("store_id", "product_id", "batch_id") + c: ClassVar[FieldProxy] = FieldProxy() store_id: int @@ -730,8 +809,14 @@ class StoreInventory(CompositePKMixin, ActiveRecord): class AsyncStoreInventory(CompositePKMixin, AsyncActiveRecord): """Async variant of StoreInventory.""" - __table_name__ = "store_inventory" - __primary_key__ = ("store_id", "product_id", "batch_id") + @classmethod + def table_name(cls): + return "store_inventory" + + @classmethod + def primary_key(cls): + return ("store_id", "product_id", "batch_id") + c: ClassVar[FieldProxy] = FieldProxy() store_id: int @@ -742,7 +827,10 @@ class AsyncStoreInventory(CompositePKMixin, AsyncActiveRecord): class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): """Single-column auto-increment PK -- backward compatibility control group.""" - __table_name__ = "orders" + @classmethod + def table_name(cls): + return "orders" + c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -751,7 +839,10 @@ class Order(IntegerPKMixin, TimestampMixin, ActiveRecord): class AsyncOrder(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """Async variant of Order.""" - __table_name__ = "orders" + @classmethod + def table_name(cls): + return "orders" + c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -760,8 +851,13 @@ class AsyncOrder(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): class MappedOrderItem(CompositePKMixin, ActiveRecord): """Composite PK with UseColumn mapping -- Python field names differ from DB column names.""" - __table_name__ = "order_items" - __primary_key__ = ("order_id", "product_id") + @classmethod + def table_name(cls): + return "order_items" + + @classmethod + def primary_key(cls): + return ("order_id", "product_id") order_ref: Annotated[int, UseColumn("order_id")] product_ref: Annotated[int, UseColumn("product_id")] @@ -771,8 +867,14 @@ class MappedOrderItem(CompositePKMixin, ActiveRecord): class AsyncMappedOrderItem(CompositePKMixin, AsyncActiveRecord): """Async variant of MappedOrderItem.""" - __table_name__ = "order_items" - __primary_key__ = ("order_id", "product_id") + @classmethod + def table_name(cls): + return "order_items" + + @classmethod + def primary_key(cls): + return ("order_id", "product_id") + c: ClassVar[FieldProxy] = FieldProxy() order_ref: Annotated[int, UseColumn("order_id")] @@ -782,7 +884,10 @@ class AsyncMappedOrderItem(CompositePKMixin, AsyncActiveRecord): class BulkUser(IntegerPKMixin, ActiveRecord): - __table_name__ = "bulk_users" + @classmethod + def table_name(cls): + return "bulk_users" + c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None @@ -792,7 +897,10 @@ class BulkUser(IntegerPKMixin, ActiveRecord): class AsyncBulkUser(IntegerPKMixin, AsyncActiveRecord): - __table_name__ = "bulk_users" + @classmethod + def table_name(cls): + return "bulk_users" + c: ClassVar[FieldProxy] = FieldProxy() id: int | None = None diff --git a/tests/rhosocial/activerecord_test/feature/basic/nopk/test_keyless_models_sqlite.py b/tests/rhosocial/activerecord_test/feature/basic/nopk/test_keyless_models_sqlite.py index d39a6e63d..2c38891be 100644 --- a/tests/rhosocial/activerecord_test/feature/basic/nopk/test_keyless_models_sqlite.py +++ b/tests/rhosocial/activerecord_test/feature/basic/nopk/test_keyless_models_sqlite.py @@ -34,24 +34,39 @@ class KeylessLog(ActiveRecord): """Insert-only table: no primary key at all.""" - __table_name__ = "ro_keyless_log" + @classmethod + def table_name(cls): + return "ro_keyless_log" + c: ClassVar[FieldProxy] = FieldProxy() - __primary_key__ = None + @classmethod + def primary_key(cls): + return None + event: str = "" level: str = "" class KeylessReadOnly(ActiveRecord, ReadOnlyMixin): """Keyless *and* read-only -- the two dimensions do not interact.""" - __table_name__ = "ro_keyless_readonly" + @classmethod + def table_name(cls): + return "ro_keyless_readonly" + c: ClassVar[FieldProxy] = FieldProxy() - __primary_key__ = None + @classmethod + def primary_key(cls): + return None + event: str = "" class ReadOnlyWithKey(ActiveRecord, ReadOnlyMixin): """Read-only but addressable -- the other direction.""" - __table_name__ = "ro_readonly_with_key" + @classmethod + def table_name(cls): + return "ro_readonly_with_key" + c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None event: str = "" @@ -59,7 +74,10 @@ class ReadOnlyWithKey(ActiveRecord, ReadOnlyMixin): class NormalModel(ActiveRecord): """Control: an ordinary addressable, writable model.""" - __table_name__ = "ro_normal_model" + @classmethod + def table_name(cls): + return "ro_normal_model" + c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None event: str = "" diff --git a/tests/rhosocial/activerecord_test/feature/basic/nopk/test_keyless_models_sqlite_async.py b/tests/rhosocial/activerecord_test/feature/basic/nopk/test_keyless_models_sqlite_async.py index 73519fc57..b2059b518 100644 --- a/tests/rhosocial/activerecord_test/feature/basic/nopk/test_keyless_models_sqlite_async.py +++ b/tests/rhosocial/activerecord_test/feature/basic/nopk/test_keyless_models_sqlite_async.py @@ -21,22 +21,37 @@ class AsyncKeylessLog(AsyncActiveRecord): - __table_name__ = "ro_keyless_log" + @classmethod + def table_name(cls): + return "ro_keyless_log" + c: ClassVar[FieldProxy] = FieldProxy() - __primary_key__ = None + @classmethod + def primary_key(cls): + return None + event: str = "" level: str = "" class AsyncKeylessReadOnly(AsyncActiveRecord, ReadOnlyMixin): - __table_name__ = "ro_keyless_readonly" + @classmethod + def table_name(cls): + return "ro_keyless_readonly" + c: ClassVar[FieldProxy] = FieldProxy() - __primary_key__ = None + @classmethod + def primary_key(cls): + return None + event: str = "" class AsyncNormalModel(AsyncActiveRecord): - __table_name__ = "ro_normal_model" + @classmethod + def table_name(cls): + return "ro_normal_model" + c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None event: str = "" diff --git a/tests/rhosocial/activerecord_test/feature/basic/readonly/test_read_only_models.py b/tests/rhosocial/activerecord_test/feature/basic/readonly/test_read_only_models.py index aac075ef1..e088dd480 100644 --- a/tests/rhosocial/activerecord_test/feature/basic/readonly/test_read_only_models.py +++ b/tests/rhosocial/activerecord_test/feature/basic/readonly/test_read_only_models.py @@ -27,14 +27,20 @@ class ReadOnlyRecord(ActiveRecord, ReadOnlyMixin): c: ClassVar[FieldProxy] = FieldProxy() - __table_name__ = "ro_readonly_records" + @classmethod + def table_name(cls): + return "ro_readonly_records" + id: Optional[int] = None label: str = "" class WritableRecord(ActiveRecord): c: ClassVar[FieldProxy] = FieldProxy() - __table_name__ = "ro_writable_records" + @classmethod + def table_name(cls): + return "ro_writable_records" + id: Optional[int] = None label: str = "" @@ -42,10 +48,15 @@ class WritableRecord(ActiveRecord): class OptedOutRecord(ActiveRecord, ReadOnlyMixin): """Declares the mixin but turns the flag off explicitly.""" c: ClassVar[FieldProxy] = FieldProxy() - __table_name__ = "ro_optedout_records" + @classmethod + def table_name(cls): + return "ro_optedout_records" + id: Optional[int] = None label: str = "" - __read_only__ = False + @classmethod + def read_only(cls): + return False class ImplementsButWritable(ActiveRecord, IReadOnlyBehavior): @@ -57,7 +68,10 @@ class ImplementsButWritable(ActiveRecord, IReadOnlyBehavior): """ c: ClassVar[FieldProxy] = FieldProxy() - __table_name__ = "ro_implements_writable" + @classmethod + def table_name(cls): + return "ro_implements_writable" + id: Optional[int] = None label: str = "" @@ -66,7 +80,10 @@ class ReadOnlyViewRecord(ActiveRecord, ReadOnlyMixin): """A read-only model that declares it maps a view.""" c: ClassVar[FieldProxy] = FieldProxy() - __table_name__ = "ro_readonly_view" + @classmethod + def table_name(cls): + return "ro_readonly_view" + id: Optional[int] = None label: str = "" @@ -332,7 +349,10 @@ def test_soft_delete_conflict_raises(self): class Conflict(ActiveRecord, ReadOnlyMixin, SoftDeleteMixin): c: ClassVar[FieldProxy] = FieldProxy() - __table_name__ = "ro_conflict_soft" + @classmethod + def table_name(cls): + return "ro_conflict_soft" + id: Optional[int] = None deleted_at: Optional[datetime] = None label: str = "" @@ -342,7 +362,10 @@ def test_timestamp_conflict_raises(self): class Conflict(ActiveRecord, ReadOnlyMixin, TimestampMixin): c: ClassVar[FieldProxy] = FieldProxy() - __table_name__ = "ro_conflict_ts" + @classmethod + def table_name(cls): + return "ro_conflict_ts" + id: Optional[int] = None created_at: Optional[datetime] = None updated_at: Optional[datetime] = None @@ -354,7 +377,10 @@ def test_no_conflict_without_read_only(self): class Fine(ActiveRecord, DefaultTimestampMixin): c: ClassVar[FieldProxy] = FieldProxy() - __table_name__ = "ro_conflict_ok" + @classmethod + def table_name(cls): + return "ro_conflict_ok" + id: Optional[int] = None Fine(id=1) diff --git a/tests/rhosocial/activerecord_test/feature/basic/readonly/test_read_only_models_async.py b/tests/rhosocial/activerecord_test/feature/basic/readonly/test_read_only_models_async.py index a81f2ad7c..a61b68640 100644 --- a/tests/rhosocial/activerecord_test/feature/basic/readonly/test_read_only_models_async.py +++ b/tests/rhosocial/activerecord_test/feature/basic/readonly/test_read_only_models_async.py @@ -20,14 +20,20 @@ class AsyncReadOnlyRecord(AsyncActiveRecord, ReadOnlyMixin): - __table_name__ = "ro_readonly_records" + @classmethod + def table_name(cls): + return "ro_readonly_records" + c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None label: str = "" class AsyncWritableRecord(AsyncActiveRecord): - __table_name__ = "ro_writable_records" + @classmethod + def table_name(cls): + return "ro_writable_records" + c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None label: str = "" @@ -63,10 +69,15 @@ def test_writable_model_does_not_answer(self): def test_interface_implementor_reporting_false_writes(self): class AsyncImplementsButWritable(AsyncActiveRecord, ReadOnlyMixin): - __table_name__ = "ro_async_implements" + @classmethod + def table_name(cls): + return "ro_async_implements" + c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None - __read_only__ = False + @classmethod + def read_only(cls): + return False assert AsyncImplementsButWritable.read_only() is False AsyncImplementsButWritable.refuse_read_only("save") @@ -150,7 +161,10 @@ def test_soft_delete_conflict_raises_at_class_definition(self): with pytest.raises(TypeError, match="read-only but also implements"): class Conflict(AsyncActiveRecord, ReadOnlyMixin, SoftDeleteMixin): - __table_name__ = "ro_async_conflict_soft" + @classmethod + def table_name(cls): + return "ro_async_conflict_soft" + c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None deleted_at: Optional[datetime] = None @@ -159,7 +173,10 @@ def test_timestamp_conflict_raises_at_class_definition(self): with pytest.raises(TypeError, match="read-only but also implements"): class Conflict(AsyncActiveRecord, ReadOnlyMixin, TimestampMixin): - __table_name__ = "ro_async_conflict_ts" + @classmethod + def table_name(cls): + return "ro_async_conflict_ts" + c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None created_at: Optional[datetime] = None diff --git a/tests/rhosocial/activerecord_test/feature/cli/conftest.py b/tests/rhosocial/activerecord_test/feature/cli/conftest.py index c17456deb..db423b19d 100644 --- a/tests/rhosocial/activerecord_test/feature/cli/conftest.py +++ b/tests/rhosocial/activerecord_test/feature/cli/conftest.py @@ -108,7 +108,9 @@ def create_order_items_table(database): class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" c: ClassVar[FieldProxy] = FieldProxy() id: int name: str @@ -117,7 +119,9 @@ class User(ActiveRecord): class AsyncUser(AsyncActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" c: ClassVar[FieldProxy] = FieldProxy() id: int name: str @@ -126,7 +130,9 @@ class AsyncUser(AsyncActiveRecord): class ReadOnlyUser(ActiveRecord, ReadOnlyMixin): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" c: ClassVar[FieldProxy] = FieldProxy() id: int name: str @@ -135,7 +141,9 @@ class ReadOnlyUser(ActiveRecord, ReadOnlyMixin): class AsyncReadOnlyUser(AsyncActiveRecord, ReadOnlyMixin): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" c: ClassVar[FieldProxy] = FieldProxy() id: int name: str @@ -144,7 +152,9 @@ class AsyncReadOnlyUser(AsyncActiveRecord, ReadOnlyMixin): class SoftUser(SoftDeleteMixin, ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" c: ClassVar[FieldProxy] = FieldProxy() id: int name: str @@ -154,7 +164,9 @@ class SoftUser(SoftDeleteMixin, ActiveRecord): class AsyncSoftUser(AsyncSoftDeleteMixin, AsyncActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" c: ClassVar[FieldProxy] = FieldProxy() id: int name: str @@ -164,7 +176,9 @@ class AsyncSoftUser(AsyncSoftDeleteMixin, AsyncActiveRecord): class ValidatedUser(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None name: str = Field(min_length=2) @@ -186,7 +200,9 @@ def check_senior_age(self): class AsyncValidatedUser(AsyncActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None name: str = Field(min_length=2) @@ -208,8 +224,12 @@ def check_senior_age(self): class KeylessUser(ActiveRecord): - __table_name__ = "users" - __primary_key__ = None + @classmethod + def table_name(cls): + return "users" + @classmethod + def primary_key(cls): + return None c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None name: str @@ -218,8 +238,12 @@ class KeylessUser(ActiveRecord): class OrderItem(ActiveRecord): - __table_name__ = "order_items" - __primary_key__ = ("order_id", "product_id") + @classmethod + def table_name(cls): + return "order_items" + @classmethod + def primary_key(cls): + return ("order_id", "product_id") order_id: int product_id: int note: str = "" diff --git a/tests/rhosocial/activerecord_test/feature/cli/test_cli_commands.py b/tests/rhosocial/activerecord_test/feature/cli/test_cli_commands.py index 02624ead9..856c85a26 100644 --- a/tests/rhosocial/activerecord_test/feature/cli/test_cli_commands.py +++ b/tests/rhosocial/activerecord_test/feature/cli/test_cli_commands.py @@ -97,7 +97,9 @@ def create_order_items_table(database): class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" id: int name: str email: Optional[str] = None @@ -105,7 +107,9 @@ class User(ActiveRecord): class AsyncUser(AsyncActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" id: int name: str email: Optional[str] = None @@ -113,8 +117,12 @@ class AsyncUser(AsyncActiveRecord): class OrderItem(ActiveRecord): - __table_name__ = "order_items" - __primary_key__ = ("order_id", "product_id") + @classmethod + def table_name(cls): + return "order_items" + @classmethod + def primary_key(cls): + return ("order_id", "product_id") order_id: int product_id: int note: str = "" @@ -170,7 +178,9 @@ def configured_env(tmp_path): class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" id: int name: str email: Optional[str] = None diff --git a/tests/rhosocial/activerecord_test/feature/connection/conftest.py b/tests/rhosocial/activerecord_test/feature/connection/conftest.py index 86bad774d..4a890f65d 100644 --- a/tests/rhosocial/activerecord_test/feature/connection/conftest.py +++ b/tests/rhosocial/activerecord_test/feature/connection/conftest.py @@ -30,7 +30,9 @@ class User(IntegerPKMixin, ActiveRecord): """Test User model.""" - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" id: Optional[int] = None name: str @@ -40,7 +42,9 @@ class User(IntegerPKMixin, ActiveRecord): class Post(IntegerPKMixin, ActiveRecord): """Test Post model.""" - __table_name__ = "posts" + @classmethod + def table_name(cls): + return "posts" id: Optional[int] = None title: str @@ -50,7 +54,9 @@ class Post(IntegerPKMixin, ActiveRecord): class Comment(IntegerPKMixin, ActiveRecord): """Test Comment model.""" - __table_name__ = "comments" + @classmethod + def table_name(cls): + return "comments" id: Optional[int] = None content: str @@ -60,7 +66,9 @@ class Comment(IntegerPKMixin, ActiveRecord): class AsyncUser(IntegerPKMixin, AsyncActiveRecord): """Test Async User model.""" - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" id: Optional[int] = None name: str @@ -70,7 +78,9 @@ class AsyncUser(IntegerPKMixin, AsyncActiveRecord): class AsyncPost(IntegerPKMixin, AsyncActiveRecord): """Test Async Post model.""" - __table_name__ = "posts" + @classmethod + def table_name(cls): + return "posts" id: Optional[int] = None title: str @@ -273,7 +283,9 @@ def session_aware_user_model() -> Type[ActiveRecord]: class SessionAwareUser(IntegerPKMixin, SessionAwareMixin, ActiveRecord): """Session-aware User model for pool testing.""" - __table_name__ = "pool_users" + @classmethod + def table_name(cls): + return "pool_users" id: Optional[int] = None name: str @@ -290,7 +302,9 @@ def session_aware_post_model() -> Type[ActiveRecord]: class SessionAwarePost(IntegerPKMixin, SessionAwareMixin, ActiveRecord): """Session-aware Post model for pool testing.""" - __table_name__ = "pool_posts" + @classmethod + def table_name(cls): + return "pool_posts" id: Optional[int] = None title: str @@ -307,7 +321,9 @@ async def async_session_aware_user_model() -> Type[AsyncActiveRecord]: class AsyncSessionAwareUser(IntegerPKMixin, AsyncSessionAwareMixin, AsyncActiveRecord): """Async session-aware User model for pool testing.""" - __table_name__ = "pool_users" + @classmethod + def table_name(cls): + return "pool_users" id: Optional[int] = None name: str diff --git a/tests/rhosocial/activerecord_test/feature/connection/test_active_record_context_awareness.py b/tests/rhosocial/activerecord_test/feature/connection/test_active_record_context_awareness.py index 3e1d41f09..1b20f8f8e 100644 --- a/tests/rhosocial/activerecord_test/feature/connection/test_active_record_context_awareness.py +++ b/tests/rhosocial/activerecord_test/feature/connection/test_active_record_context_awareness.py @@ -39,7 +39,9 @@ async def async_execute_sql(backend, sql: str, params=None): class ContextTestUser(IntegerPKMixin, ActiveRecord): """Test User model for context awareness tests.""" - __table_name__ = "ctx_test_users" + @classmethod + def table_name(cls): + return "ctx_test_users" id: Optional[int] = None name: str @@ -50,7 +52,9 @@ class ContextTestUser(IntegerPKMixin, ActiveRecord): class AsyncContextTestUser(IntegerPKMixin, AsyncActiveRecord): """Test Async User model for context awareness tests.""" - __table_name__ = "async_ctx_test_users" + @classmethod + def table_name(cls): + return "async_ctx_test_users" id: Optional[int] = None name: str diff --git a/tests/rhosocial/activerecord_test/feature/connection/test_backend_group_context_lifecycle.py b/tests/rhosocial/activerecord_test/feature/connection/test_backend_group_context_lifecycle.py index 08bd44830..3356817cf 100644 --- a/tests/rhosocial/activerecord_test/feature/connection/test_backend_group_context_lifecycle.py +++ b/tests/rhosocial/activerecord_test/feature/connection/test_backend_group_context_lifecycle.py @@ -35,7 +35,9 @@ class ContextUser(IntegerPKMixin, ActiveRecord): """Test User model for context lifecycle tests.""" - __table_name__ = "context_users" + @classmethod + def table_name(cls): + return "context_users" id: Optional[int] = None name: str @@ -45,7 +47,9 @@ class ContextUser(IntegerPKMixin, ActiveRecord): class ContextPost(IntegerPKMixin, ActiveRecord): """Test Post model for context lifecycle tests.""" - __table_name__ = "context_posts" + @classmethod + def table_name(cls): + return "context_posts" id: Optional[int] = None title: str @@ -55,7 +59,9 @@ class ContextPost(IntegerPKMixin, ActiveRecord): class AsyncContextUser(IntegerPKMixin, AsyncActiveRecord): """Test async User model for context lifecycle tests.""" - __table_name__ = "context_users" + @classmethod + def table_name(cls): + return "context_users" id: Optional[int] = None name: str @@ -65,7 +71,9 @@ class AsyncContextUser(IntegerPKMixin, AsyncActiveRecord): class AsyncContextPost(IntegerPKMixin, AsyncActiveRecord): """Test async Post model for context lifecycle tests.""" - __table_name__ = "context_posts" + @classmethod + def table_name(cls): + return "context_posts" id: Optional[int] = None title: str diff --git a/tests/rhosocial/activerecord_test/feature/connection/test_backend_group_manual_lifecycle.py b/tests/rhosocial/activerecord_test/feature/connection/test_backend_group_manual_lifecycle.py index 3a086f48c..e775664fb 100644 --- a/tests/rhosocial/activerecord_test/feature/connection/test_backend_group_manual_lifecycle.py +++ b/tests/rhosocial/activerecord_test/feature/connection/test_backend_group_manual_lifecycle.py @@ -33,7 +33,9 @@ class ManualUser(IntegerPKMixin, ActiveRecord): """Test User model for manual lifecycle tests.""" - __table_name__ = "manual_users" + @classmethod + def table_name(cls): + return "manual_users" id: Optional[int] = None name: str @@ -43,7 +45,9 @@ class ManualUser(IntegerPKMixin, ActiveRecord): class ManualPost(IntegerPKMixin, ActiveRecord): """Test Post model for manual lifecycle tests.""" - __table_name__ = "manual_posts" + @classmethod + def table_name(cls): + return "manual_posts" id: Optional[int] = None title: str @@ -53,7 +57,9 @@ class ManualPost(IntegerPKMixin, ActiveRecord): class AsyncManualUser(IntegerPKMixin, AsyncActiveRecord): """Test async User model for manual lifecycle tests.""" - __table_name__ = "manual_users" + @classmethod + def table_name(cls): + return "manual_users" id: Optional[int] = None name: str @@ -63,7 +69,9 @@ class AsyncManualUser(IntegerPKMixin, AsyncActiveRecord): class AsyncManualPost(IntegerPKMixin, AsyncActiveRecord): """Test async Post model for manual lifecycle tests.""" - __table_name__ = "manual_posts" + @classmethod + def table_name(cls): + return "manual_posts" id: Optional[int] = None title: str diff --git a/tests/rhosocial/activerecord_test/feature/events/fixtures/models.py b/tests/rhosocial/activerecord_test/feature/events/fixtures/models.py index 5d7d69f9b..93c609197 100644 --- a/tests/rhosocial/activerecord_test/feature/events/fixtures/models.py +++ b/tests/rhosocial/activerecord_test/feature/events/fixtures/models.py @@ -19,7 +19,9 @@ class EventTestModel(IntegerPKMixin, TimestampMixin, ActiveRecord): """A model class for testing event mechanisms""" - __table_name__ = "event_tests" + @classmethod + def table_name(cls): + return "event_tests" id: Optional[int] = None name: str @@ -46,7 +48,9 @@ def clear_event_logs(self): class AsyncEventTestModel(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """An async model class for testing event mechanisms""" - __table_name__ = "event_tests" + @classmethod + def table_name(cls): + return "event_tests" id: Optional[int] = None name: str @@ -73,7 +77,9 @@ def clear_event_logs(self): class EventTrackingModel(IntegerPKMixin, ActiveRecord): """A model for testing event tracking functionality.""" - __table_name__ = "event_tracking_models" + @classmethod + def table_name(cls): + return "event_tracking_models" title: str content: str diff --git a/tests/rhosocial/activerecord_test/feature/events/fixtures/models_py310.py b/tests/rhosocial/activerecord_test/feature/events/fixtures/models_py310.py index 6cc0bf93d..bd3ee5e0b 100644 --- a/tests/rhosocial/activerecord_test/feature/events/fixtures/models_py310.py +++ b/tests/rhosocial/activerecord_test/feature/events/fixtures/models_py310.py @@ -27,7 +27,9 @@ class EventTestModel(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ - __table_name__ = "event_tests" + @classmethod + def table_name(cls): + return "event_tests" id: int | None = None name: str @@ -57,7 +59,9 @@ class EventTrackingModel(IntegerPKMixin, ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ - __table_name__ = "event_tracking_models" + @classmethod + def table_name(cls): + return "event_tracking_models" title: str content: str diff --git a/tests/rhosocial/activerecord_test/feature/events/fixtures/models_py311.py b/tests/rhosocial/activerecord_test/feature/events/fixtures/models_py311.py index f48aa4674..786f4f32d 100644 --- a/tests/rhosocial/activerecord_test/feature/events/fixtures/models_py311.py +++ b/tests/rhosocial/activerecord_test/feature/events/fixtures/models_py311.py @@ -29,7 +29,9 @@ class EventTestModel(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.11+ version using | syntax and Self type. """ - __table_name__ = "event_tests" + @classmethod + def table_name(cls): + return "event_tests" id: int | None = None name: str @@ -70,7 +72,9 @@ class EventTrackingModel(IntegerPKMixin, ActiveRecord): Python 3.11+ version using | syntax and Self type. """ - __table_name__ = "event_tracking_models" + @classmethod + def table_name(cls): + return "event_tracking_models" title: str content: str diff --git a/tests/rhosocial/activerecord_test/feature/events/fixtures/models_py312.py b/tests/rhosocial/activerecord_test/feature/events/fixtures/models_py312.py index b22a4547c..9196e1a0a 100644 --- a/tests/rhosocial/activerecord_test/feature/events/fixtures/models_py312.py +++ b/tests/rhosocial/activerecord_test/feature/events/fixtures/models_py312.py @@ -30,7 +30,9 @@ class EventTestModel(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ - __table_name__ = "event_tests" + @classmethod + def table_name(cls): + return "event_tests" id: int | None = None name: str @@ -71,7 +73,9 @@ class EventTrackingModel(IntegerPKMixin, ActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ - __table_name__ = "event_tracking_models" + @classmethod + def table_name(cls): + return "event_tracking_models" title: str content: str diff --git a/tests/rhosocial/activerecord_test/feature/interface/test_composite_pk_identity.py b/tests/rhosocial/activerecord_test/feature/interface/test_composite_pk_identity.py index 41741ac15..14ef980bd 100644 --- a/tests/rhosocial/activerecord_test/feature/interface/test_composite_pk_identity.py +++ b/tests/rhosocial/activerecord_test/feature/interface/test_composite_pk_identity.py @@ -34,8 +34,13 @@ class PartitionedLog(CompositePKMixin, ActiveRecord): """One key field the database generates, one the caller supplies.""" - __table_name__ = "ct_partitioned_log" - __primary_key__ = ("id", "bucket") + @classmethod + def table_name(cls): + return "ct_partitioned_log" + + @classmethod + def primary_key(cls): + return ("id", "bucket") id: GeneratedId = None # A supplied key field is optional at declaration and required at save time: @@ -47,8 +52,13 @@ class PartitionedLog(CompositePKMixin, ActiveRecord): class AsyncPartitionedLog(CompositePKMixin, AsyncActiveRecord): """The asynchronous twin, so both _prepare_save_data paths are covered.""" - __table_name__ = "ct_partitioned_log" - __primary_key__ = ("id", "bucket") + @classmethod + def table_name(cls): + return "ct_partitioned_log" + + @classmethod + def primary_key(cls): + return ("id", "bucket") id: GeneratedId = None bucket: Optional[str] = None @@ -58,8 +68,13 @@ class AsyncPartitionedLog(CompositePKMixin, AsyncActiveRecord): class PlainComposite(CompositePKMixin, ActiveRecord): """A composite key with no generated member: every field is the caller's.""" - __table_name__ = "ct_plain_composite" - __primary_key__ = ("order_id", "product_id") + @classmethod + def table_name(cls): + return "ct_plain_composite" + + @classmethod + def primary_key(cls): + return ("order_id", "product_id") order_id: int = 0 product_id: int = 0 @@ -69,7 +84,9 @@ class PlainComposite(CompositePKMixin, ActiveRecord): class SingleKey(ActiveRecord): """The ordinary model this change must not disturb.""" - __table_name__ = "ct_single_key" + @classmethod + def table_name(cls): + return "ct_single_key" id: Optional[int] = None name: str = "" diff --git a/tests/rhosocial/activerecord_test/feature/interface/test_partial_key_scope_warning.py b/tests/rhosocial/activerecord_test/feature/interface/test_partial_key_scope_warning.py index 04ef41c43..41e9b81bd 100644 --- a/tests/rhosocial/activerecord_test/feature/interface/test_partial_key_scope_warning.py +++ b/tests/rhosocial/activerecord_test/feature/interface/test_partial_key_scope_warning.py @@ -50,8 +50,14 @@ def emit(self, record: logging.LogRecord) -> None: class PartitionedLog(CompositePKMixin, ActiveRecord): """A partitioned log: surrogate generated, month supplied, job_id plain.""" - __table_name__ = "ct_job_logs_scope" - __primary_key__ = ("id", "bucket") + @classmethod + def table_name(cls): + return "ct_job_logs_scope" + + @classmethod + def primary_key(cls): + return ("id", "bucket") + c: ClassVar[FieldProxy] = FieldProxy() id: Annotated[Optional[int], UseColumnAttributes(IdentityAttribute())] = None @@ -63,8 +69,14 @@ class PartitionedLog(CompositePKMixin, ActiveRecord): class AsyncPartitionedLog(CompositePKMixin, AsyncActiveRecord): """The async twin, so both query classes are covered.""" - __table_name__ = "ct_job_logs_scope" - __primary_key__ = ("id", "bucket") + @classmethod + def table_name(cls): + return "ct_job_logs_scope" + + @classmethod + def primary_key(cls): + return ("id", "bucket") + c: ClassVar[FieldProxy] = FieldProxy() id: Annotated[Optional[int], UseColumnAttributes(IdentityAttribute())] = None @@ -75,7 +87,10 @@ class AsyncPartitionedLog(CompositePKMixin, AsyncActiveRecord): class PlainModel(ActiveRecord): """An ordinary single-key model: nothing here can be partial.""" - __table_name__ = "ct_plain_scope" + @classmethod + def table_name(cls): + return "ct_plain_scope" + c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None diff --git a/tests/rhosocial/activerecord_test/feature/mixins/fixtures/models.py b/tests/rhosocial/activerecord_test/feature/mixins/fixtures/models.py index 20183e74e..921aa2498 100644 --- a/tests/rhosocial/activerecord_test/feature/mixins/fixtures/models.py +++ b/tests/rhosocial/activerecord_test/feature/mixins/fixtures/models.py @@ -17,7 +17,9 @@ class TimestampedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): """Blog post model with timestamps""" - __table_name__ = "timestamped_posts" + @classmethod + def table_name(cls): + return "timestamped_posts" id: Optional[int] = None title: str @@ -26,7 +28,9 @@ class TimestampedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): class VersionedProduct(IntegerPKMixin, OptimisticLockMixin, ActiveRecord): """Product model with optimistic locking""" - __table_name__ = "versioned_products" + @classmethod + def table_name(cls): + return "versioned_products" id: Optional[int] = None name: str @@ -35,7 +39,9 @@ class VersionedProduct(IntegerPKMixin, OptimisticLockMixin, ActiveRecord): class Task(IntegerPKMixin, SoftDeleteMixin, ActiveRecord): """Task model supporting soft deletion""" - __table_name__ = "tasks" + @classmethod + def table_name(cls): + return "tasks" id: Optional[int] = None title: str @@ -44,7 +50,9 @@ class Task(IntegerPKMixin, SoftDeleteMixin, ActiveRecord): class CombinedArticle(IntegerPKMixin, TimestampMixin, OptimisticLockMixin, SoftDeleteMixin, ActiveRecord): """Article model combining all mixins""" - __table_name__ = "combined_articles" + @classmethod + def table_name(cls): + return "combined_articles" id: Optional[int] = None title: str @@ -54,7 +62,9 @@ class CombinedArticle(IntegerPKMixin, TimestampMixin, OptimisticLockMixin, SoftD class AsyncTimestampedPost(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): """Blog post model with timestamps (async version)""" - __table_name__ = "timestamped_posts" + @classmethod + def table_name(cls): + return "timestamped_posts" id: Optional[int] = None title: str @@ -63,7 +73,9 @@ class AsyncTimestampedPost(IntegerPKMixin, TimestampMixin, AsyncActiveRecord): class AsyncVersionedProduct(IntegerPKMixin, OptimisticLockMixin, AsyncActiveRecord): """Product model with optimistic locking (async version)""" - __table_name__ = "versioned_products" + @classmethod + def table_name(cls): + return "versioned_products" id: Optional[int] = None name: str @@ -72,7 +84,9 @@ class AsyncVersionedProduct(IntegerPKMixin, OptimisticLockMixin, AsyncActiveReco class AsyncTask(IntegerPKMixin, AsyncSoftDeleteMixin, AsyncActiveRecord): """Task model supporting soft deletion (async version)""" - __table_name__ = "tasks" + @classmethod + def table_name(cls): + return "tasks" id: Optional[int] = None title: str @@ -81,7 +95,9 @@ class AsyncTask(IntegerPKMixin, AsyncSoftDeleteMixin, AsyncActiveRecord): class AsyncCombinedArticle(IntegerPKMixin, TimestampMixin, OptimisticLockMixin, AsyncSoftDeleteMixin, AsyncActiveRecord): """Article model combining all mixins (async version)""" - __table_name__ = "combined_articles" + @classmethod + def table_name(cls): + return "combined_articles" id: Optional[int] = None title: str diff --git a/tests/rhosocial/activerecord_test/feature/mixins/fixtures/models_py310.py b/tests/rhosocial/activerecord_test/feature/mixins/fixtures/models_py310.py index b300b2ebb..b7d0778e6 100644 --- a/tests/rhosocial/activerecord_test/feature/mixins/fixtures/models_py310.py +++ b/tests/rhosocial/activerecord_test/feature/mixins/fixtures/models_py310.py @@ -24,7 +24,9 @@ class TimestampedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ - __table_name__ = "timestamped_posts" + @classmethod + def table_name(cls): + return "timestamped_posts" id: int | None = None title: str @@ -36,7 +38,9 @@ class VersionedProduct(IntegerPKMixin, OptimisticLockMixin, ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ - __table_name__ = "versioned_products" + @classmethod + def table_name(cls): + return "versioned_products" id: int | None = None name: str @@ -48,7 +52,9 @@ class Task(IntegerPKMixin, SoftDeleteMixin, ActiveRecord): Python 3.10+ version using | syntax instead of Optional. """ - __table_name__ = "tasks" + @classmethod + def table_name(cls): + return "tasks" id: int | None = None title: str @@ -60,7 +66,9 @@ class CombinedArticle(IntegerPKMixin, TimestampMixin, OptimisticLockMixin, SoftD Python 3.10+ version using | syntax instead of Optional. """ - __table_name__ = "combined_articles" + @classmethod + def table_name(cls): + return "combined_articles" id: int | None = None title: str diff --git a/tests/rhosocial/activerecord_test/feature/mixins/fixtures/models_py311.py b/tests/rhosocial/activerecord_test/feature/mixins/fixtures/models_py311.py index 41fbfe2da..978afa7fa 100644 --- a/tests/rhosocial/activerecord_test/feature/mixins/fixtures/models_py311.py +++ b/tests/rhosocial/activerecord_test/feature/mixins/fixtures/models_py311.py @@ -27,7 +27,9 @@ class TimestampedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.11+ version using | syntax and Self type. """ - __table_name__ = "timestamped_posts" + @classmethod + def table_name(cls): + return "timestamped_posts" id: int | None = None title: str @@ -44,7 +46,9 @@ class VersionedProduct(IntegerPKMixin, OptimisticLockMixin, ActiveRecord): Python 3.11+ version using | syntax and Self type. """ - __table_name__ = "versioned_products" + @classmethod + def table_name(cls): + return "versioned_products" id: int | None = None name: str @@ -61,7 +65,9 @@ class Task(IntegerPKMixin, SoftDeleteMixin, ActiveRecord): Python 3.11+ version using | syntax and Self type. """ - __table_name__ = "tasks" + @classmethod + def table_name(cls): + return "tasks" id: int | None = None title: str @@ -83,7 +89,9 @@ class CombinedArticle(IntegerPKMixin, TimestampMixin, OptimisticLockMixin, SoftD Python 3.11+ version using | syntax and Self type. """ - __table_name__ = "combined_articles" + @classmethod + def table_name(cls): + return "combined_articles" id: int | None = None title: str diff --git a/tests/rhosocial/activerecord_test/feature/mixins/fixtures/models_py312.py b/tests/rhosocial/activerecord_test/feature/mixins/fixtures/models_py312.py index c2c942f4c..176fabe4a 100644 --- a/tests/rhosocial/activerecord_test/feature/mixins/fixtures/models_py312.py +++ b/tests/rhosocial/activerecord_test/feature/mixins/fixtures/models_py312.py @@ -28,7 +28,9 @@ class TimestampedPost(IntegerPKMixin, TimestampMixin, ActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ - __table_name__ = "timestamped_posts" + @classmethod + def table_name(cls): + return "timestamped_posts" id: int | None = None title: str @@ -51,7 +53,9 @@ class VersionedProduct(IntegerPKMixin, OptimisticLockMixin, ActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ - __table_name__ = "versioned_products" + @classmethod + def table_name(cls): + return "versioned_products" id: int | None = None name: str @@ -74,7 +78,9 @@ class Task(IntegerPKMixin, SoftDeleteMixin, ActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ - __table_name__ = "tasks" + @classmethod + def table_name(cls): + return "tasks" id: int | None = None title: str @@ -102,7 +108,9 @@ class CombinedArticle(IntegerPKMixin, TimestampMixin, OptimisticLockMixin, SoftD Python 3.12+ version using | syntax, Self type, and @override. """ - __table_name__ = "combined_articles" + @classmethod + def table_name(cls): + return "combined_articles" id: int | None = None title: str diff --git a/tests/rhosocial/activerecord_test/feature/mixins/test_field_config_validation.py b/tests/rhosocial/activerecord_test/feature/mixins/test_field_config_validation.py index 5ba81ea74..a772036ef 100644 --- a/tests/rhosocial/activerecord_test/feature/mixins/test_field_config_validation.py +++ b/tests/rhosocial/activerecord_test/feature/mixins/test_field_config_validation.py @@ -23,7 +23,7 @@ class Order(ActiveRecord, SoftDeleteMixin): # mixin's __init__ is skipped # tests/rhosocial/activerecord_test/feature/mixins/test_field_config_validation.py from datetime import datetime -from typing import ClassVar, Optional +from typing import Optional import pytest from pydantic import Field @@ -48,73 +48,73 @@ def test_soft_delete_rejects_missing_field_with_active_record_first(): statement succeeded and the model broke at query time instead. """ - with pytest.raises(TypeError, match="__deleted_at_field__"): + with pytest.raises(TypeError, match="deleted_at_field\\(\\)"): type( # noqa: F841 -- the statement itself is the assertion "Order", (ActiveRecord, SoftDeleteMixin), { - "__table_name__": "rej_soft_a", - "__primary_key__": "id", + "table_name": classmethod(lambda cls: "rej_soft_a"), + "primary_key": classmethod(lambda cls: "id"), "__annotations__": {"id": Optional[int]}, - "__deleted_at_field__": "no_such_field", + "deleted_at_field": classmethod(lambda cls: "no_such_field"), }, ) def test_soft_delete_rejects_missing_field_with_mixin_first(): """The other base order must behave identically.""" - with pytest.raises(TypeError, match="__deleted_at_field__"): + with pytest.raises(TypeError, match="deleted_at_field\\(\\)"): type( # noqa: F841 "Order", (SoftDeleteMixin, ActiveRecord), { - "__table_name__": "rej_soft_b", - "__primary_key__": "id", + "table_name": classmethod(lambda cls: "rej_soft_b"), + "primary_key": classmethod(lambda cls: "id"), "__annotations__": {"id": Optional[int]}, - "__deleted_at_field__": "no_such_field", + "deleted_at_field": classmethod(lambda cls: "no_such_field"), }, ) def test_timestamp_rejects_missing_field(): - with pytest.raises(TypeError, match="__created_at_field__"): + with pytest.raises(TypeError, match="created_at_field\\(\\)"): type( # noqa: F841 "Article", (ActiveRecord, TimestampMixin), { - "__table_name__": "rej_ts", - "__primary_key__": "id", + "table_name": classmethod(lambda cls: "rej_ts"), + "primary_key": classmethod(lambda cls: "id"), "__annotations__": {"id": Optional[int]}, - "__created_at_field__": "no_such_field", + "created_at_field": classmethod(lambda cls: "no_such_field"), }, ) def test_optimistic_lock_rejects_missing_field(): - with pytest.raises(TypeError, match="__version_field__"): + with pytest.raises(TypeError, match="version_field\\(\\)"): type( # noqa: F841 "Account", (ActiveRecord, OptimisticLockMixin), { - "__table_name__": "rej_lock", - "__primary_key__": "id", + "table_name": classmethod(lambda cls: "rej_lock"), + "primary_key": classmethod(lambda cls: "id"), "__annotations__": {"id": Optional[int]}, - "__version_field__": "no_such_field", + "version_field": classmethod(lambda cls: "no_such_field"), }, ) def test_optimistic_lock_rejects_non_positive_increment(): - with pytest.raises(ValueError, match="__version_increment_by__"): + with pytest.raises(ValueError, match="version_increment_by\\(\\)"): type( # noqa: F841 "Account", (ActiveRecord, OptimisticLockMixin), { - "__table_name__": "rej_lock_inc", - "__primary_key__": "id", + "table_name": classmethod(lambda cls: "rej_lock_inc"), + "primary_key": classmethod(lambda cls: "id"), "__annotations__": {"id": Optional[int], "version": int}, "version": 1, - "__version_increment_by__": 0, + "version_increment_by": classmethod(lambda cls: 0), }, ) @@ -126,45 +126,65 @@ def test_optimistic_lock_rejects_non_positive_increment(): def test_soft_delete_accepts_declared_field(): class Doc(ActiveRecord, SoftDeleteMixin): - __table_name__ = "ok_soft" - __primary_key__: ClassVar[str] = "id" + @classmethod + def table_name(cls): + return "ok_soft" + + @classmethod + def primary_key(cls): + return "id" id: Optional[int] = None deleted_at: Optional[datetime] = Field(default=None) - assert Doc.__deleted_at_field__ in Doc.model_fields + assert Doc.deleted_at_field() in Doc.model_fields def test_timestamp_accepts_declared_fields(): class Post(ActiveRecord, TimestampMixin): - __table_name__ = "ok_ts" - __primary_key__: ClassVar[str] = "id" + @classmethod + def table_name(cls): + return "ok_ts" + + @classmethod + def primary_key(cls): + return "id" id: Optional[int] = None created_at: datetime = Field(default_factory=datetime.now) updated_at: datetime = Field(default_factory=datetime.now) - assert Post.__created_at_field__ in Post.model_fields - assert Post.__updated_at_field__ in Post.model_fields + assert Post.created_at_field() in Post.model_fields + assert Post.updated_at_field() in Post.model_fields def test_optimistic_lock_accepts_declared_field(): class Account(ActiveRecord, OptimisticLockMixin): - __table_name__ = "ok_lock" - __primary_key__: ClassVar[str] = "id" + @classmethod + def table_name(cls): + return "ok_lock" + + @classmethod + def primary_key(cls): + return "id" id: Optional[int] = None version: int = 1 - assert Account.__version_field__ in Account.model_fields + assert Account.version_field() in Account.model_fields def test_default_soft_delete_mixin_declares_its_own_field(): """The ``Default*`` mixins ship the field, so they validate themselves.""" class Doc(ActiveRecord, DefaultSoftDeleteMixin): - __table_name__ = "ok_default_soft" - __primary_key__: ClassVar[str] = "id" + @classmethod + def table_name(cls): + return "ok_default_soft" + + @classmethod + def primary_key(cls): + return "id" id: Optional[int] = None @@ -181,8 +201,13 @@ def test_validation_does_not_mask_other_feature_handlers(): """ class Doc(ActiveRecord, SoftDeleteMixin): - __table_name__ = "ok_handlers" - __primary_key__: ClassVar[str] = "id" + @classmethod + def table_name(cls): + return "ok_handlers" + + @classmethod + def primary_key(cls): + return "id" id: Optional[int] = None deleted_at: Optional[datetime] = Field(default=None) diff --git a/tests/rhosocial/activerecord_test/feature/mixins/test_utc_timestamp.py b/tests/rhosocial/activerecord_test/feature/mixins/test_utc_timestamp.py index 4f1fae48f..2a0e10c14 100644 --- a/tests/rhosocial/activerecord_test/feature/mixins/test_utc_timestamp.py +++ b/tests/rhosocial/activerecord_test/feature/mixins/test_utc_timestamp.py @@ -25,7 +25,9 @@ class UtcEvent(UtcTimestampMixin, IntegerPKMixin, DefaultTimestampMixin, ActiveRecord): """Synchronous model exercising the optional UTC validator.""" - __table_name__ = "utc_events" + @classmethod + def table_name(cls): + return "utc_events" id: Optional[int] = None name: str @@ -37,7 +39,9 @@ class AsyncUtcEvent( ): """Asynchronous twin with the same fields as UtcEvent.""" - __table_name__ = "utc_events" + @classmethod + def table_name(cls): + return "utc_events" id: Optional[int] = None name: str diff --git a/tests/rhosocial/activerecord_test/feature/query/test_column_type_support.py b/tests/rhosocial/activerecord_test/feature/query/test_column_type_support.py index a837422c5..6fe71335a 100644 --- a/tests/rhosocial/activerecord_test/feature/query/test_column_type_support.py +++ b/tests/rhosocial/activerecord_test/feature/query/test_column_type_support.py @@ -402,8 +402,14 @@ def _declared_model(dialect): pytest.skip("typing.Annotated requires Python 3.9+") class Declared(ActiveRecord): - __table_name__ = "declared_types" - __primary_key__: ClassVar[str] = "id" + @classmethod + def table_name(cls): + return "declared_types" + + @classmethod + def primary_key(cls): + return "id" + c: ClassVar[FieldProxy] = FieldProxy() id: int @@ -430,8 +436,14 @@ def test_two_column_type_declarations_on_one_field_are_refused(): pytest.skip("typing.Annotated requires Python 3.9+") class Bad(ActiveRecord): - __table_name__ = "bad_declarations" - __primary_key__: ClassVar[str] = "id" + @classmethod + def table_name(cls): + return "bad_declarations" + + @classmethod + def primary_key(cls): + return "id" + c: ClassVar[FieldProxy] = FieldProxy() id: int diff --git a/tests/rhosocial/activerecord_test/feature/query/test_typed_columns.py b/tests/rhosocial/activerecord_test/feature/query/test_typed_columns.py index fc3a80c86..41e48620f 100644 --- a/tests/rhosocial/activerecord_test/feature/query/test_typed_columns.py +++ b/tests/rhosocial/activerecord_test/feature/query/test_typed_columns.py @@ -325,8 +325,14 @@ def _model(dialect): Annotated = _annotated() class Widget(ActiveRecord): - __table_name__ = "typed_widget" - __primary_key__: ClassVar[str] = "id" + @classmethod + def table_name(cls): + return "typed_widget" + + @classmethod + def primary_key(cls): + return "id" + c: ClassVar[FieldProxy] = FieldProxy() id: int @@ -376,8 +382,14 @@ def test_field_proxy_refuses_an_undeclared_any(): """ class Loose(ActiveRecord): - __table_name__ = "typed_loose" - __primary_key__: ClassVar[str] = "id" + @classmethod + def table_name(cls): + return "typed_loose" + + @classmethod + def primary_key(cls): + return "id" + c: ClassVar[FieldProxy] = FieldProxy() id: int diff --git a/tests/rhosocial/activerecord_test/feature/relation/fixtures/models.py b/tests/rhosocial/activerecord_test/feature/relation/fixtures/models.py index 1c6c12b5a..3149a627e 100644 --- a/tests/rhosocial/activerecord_test/feature/relation/fixtures/models.py +++ b/tests/rhosocial/activerecord_test/feature/relation/fixtures/models.py @@ -24,7 +24,9 @@ # ── Basic Relation Models ──────────────────────────────── class Employee(ActiveRecord): - __table_name__ = "employees" + @classmethod + def table_name(cls): + return "employees" id: Optional[int] = None username: str @@ -37,7 +39,9 @@ class Employee(ActiveRecord): class Department(ActiveRecord): - __table_name__ = "departments" + @classmethod + def table_name(cls): + return "departments" id: Optional[int] = None name: str @@ -50,7 +54,9 @@ class Department(ActiveRecord): class Author(ActiveRecord): - __table_name__ = "authors" + @classmethod + def table_name(cls): + return "authors" id: Optional[int] = None name: str @@ -66,7 +72,9 @@ class Author(ActiveRecord): class Book(ActiveRecord): - __table_name__ = "books" + @classmethod + def table_name(cls): + return "books" id: Optional[int] = None title: str @@ -83,7 +91,9 @@ class Book(ActiveRecord): class Chapter(ActiveRecord): - __table_name__ = "chapters" + @classmethod + def table_name(cls): + return "chapters" id: Optional[int] = None title: str @@ -96,7 +106,9 @@ class Chapter(ActiveRecord): class Profile(ActiveRecord): - __table_name__ = "profiles" + @classmethod + def table_name(cls): + return "profiles" id: Optional[int] = None bio: str @@ -111,7 +123,9 @@ class Profile(ActiveRecord): # ── Advanced Relation Models (with FieldProxy, DerivedField, JSON) ── class User(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -142,7 +156,9 @@ class User(ActiveRecord): class Post(ActiveRecord): - __table_name__ = "posts" + @classmethod + def table_name(cls): + return "posts" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -184,7 +200,9 @@ class Post(ActiveRecord): class Comment(ActiveRecord): - __table_name__ = "comments" + @classmethod + def table_name(cls): + return "comments" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -212,7 +230,9 @@ class Comment(ActiveRecord): # ── Async Models ───────────────────────────────────────── class AsyncUser(AsyncActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -239,7 +259,9 @@ class AsyncUser(AsyncActiveRecord): class AsyncPost(AsyncActiveRecord): - __table_name__ = "posts" + @classmethod + def table_name(cls): + return "posts" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -276,7 +298,9 @@ class AsyncPost(AsyncActiveRecord): class AsyncComment(AsyncActiveRecord): - __table_name__ = "comments" + @classmethod + def table_name(cls): + return "comments" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -301,7 +325,9 @@ class AsyncComment(AsyncActiveRecord): # ── Relation Boundary Models ────────────────────────────── class BoundaryOwner(ActiveRecord): - __table_name__ = "relation_boundary_owners" + @classmethod + def table_name(cls): + return "relation_boundary_owners" id: Optional[int] = None name: str @@ -317,7 +343,9 @@ class BoundaryOwner(ActiveRecord): class BoundaryProfile(ActiveRecord): - __table_name__ = "relation_boundary_profiles" + @classmethod + def table_name(cls): + return "relation_boundary_profiles" id: Optional[int] = None bio: str @@ -330,7 +358,9 @@ class BoundaryProfile(ActiveRecord): class BoundaryPost(ActiveRecord): - __table_name__ = "relation_boundary_posts" + @classmethod + def table_name(cls): + return "relation_boundary_posts" id: Optional[int] = None title: str @@ -343,7 +373,9 @@ class BoundaryPost(ActiveRecord): class AsyncBoundaryOwner(AsyncActiveRecord): - __table_name__ = "relation_boundary_owners" + @classmethod + def table_name(cls): + return "relation_boundary_owners" id: Optional[int] = None name: str @@ -359,7 +391,9 @@ class AsyncBoundaryOwner(AsyncActiveRecord): class AsyncBoundaryProfile(AsyncActiveRecord): - __table_name__ = "relation_boundary_profiles" + @classmethod + def table_name(cls): + return "relation_boundary_profiles" id: Optional[int] = None bio: str @@ -372,7 +406,9 @@ class AsyncBoundaryProfile(AsyncActiveRecord): class AsyncBoundaryPost(AsyncActiveRecord): - __table_name__ = "relation_boundary_posts" + @classmethod + def table_name(cls): + return "relation_boundary_posts" id: Optional[int] = None title: str diff --git a/tests/rhosocial/activerecord_test/feature/relation/fixtures/models_py310.py b/tests/rhosocial/activerecord_test/feature/relation/fixtures/models_py310.py index 20e101659..e91bf2c14 100644 --- a/tests/rhosocial/activerecord_test/feature/relation/fixtures/models_py310.py +++ b/tests/rhosocial/activerecord_test/feature/relation/fixtures/models_py310.py @@ -22,7 +22,9 @@ class Employee(ActiveRecord): Python 3.10+ version. """ - __table_name__ = "employees" + @classmethod + def table_name(cls): + return "employees" id: int | None = None username: str @@ -40,7 +42,9 @@ class Department(ActiveRecord): Python 3.10+ version. """ - __table_name__ = "departments" + @classmethod + def table_name(cls): + return "departments" id: int | None = None name: str @@ -58,7 +62,9 @@ class Author(ActiveRecord): Python 3.10+ version. """ - __table_name__ = "authors" + @classmethod + def table_name(cls): + return "authors" id: int | None = None name: str @@ -79,7 +85,9 @@ class Book(ActiveRecord): Python 3.10+ version. """ - __table_name__ = "books" + @classmethod + def table_name(cls): + return "books" id: int | None = None title: str @@ -101,7 +109,9 @@ class Chapter(ActiveRecord): Python 3.10+ version. """ - __table_name__ = "chapters" + @classmethod + def table_name(cls): + return "chapters" id: int | None = None title: str @@ -119,7 +129,9 @@ class Profile(ActiveRecord): Python 3.10+ version. """ - __table_name__ = "profiles" + @classmethod + def table_name(cls): + return "profiles" id: int | None = None bio: str diff --git a/tests/rhosocial/activerecord_test/feature/relation/fixtures/models_py311.py b/tests/rhosocial/activerecord_test/feature/relation/fixtures/models_py311.py index a6835c3a6..ea692f3c6 100644 --- a/tests/rhosocial/activerecord_test/feature/relation/fixtures/models_py311.py +++ b/tests/rhosocial/activerecord_test/feature/relation/fixtures/models_py311.py @@ -25,7 +25,9 @@ class Employee(ActiveRecord): Python 3.11+ version using | syntax and Self type. """ - __table_name__ = "employees" + @classmethod + def table_name(cls): + return "employees" id: int | None = None username: str @@ -48,7 +50,9 @@ class Department(ActiveRecord): Python 3.11+ version using | syntax and Self type. """ - __table_name__ = "departments" + @classmethod + def table_name(cls): + return "departments" id: int | None = None name: str @@ -71,7 +75,9 @@ class Author(ActiveRecord): Python 3.11+ version using | syntax and Self type. """ - __table_name__ = "authors" + @classmethod + def table_name(cls): + return "authors" id: int | None = None name: str @@ -97,7 +103,9 @@ class Book(ActiveRecord): Python 3.11+ version using | syntax and Self type. """ - __table_name__ = "books" + @classmethod + def table_name(cls): + return "books" id: int | None = None title: str @@ -124,7 +132,9 @@ class Chapter(ActiveRecord): Python 3.11+ version using | syntax and Self type. """ - __table_name__ = "chapters" + @classmethod + def table_name(cls): + return "chapters" id: int | None = None title: str @@ -147,7 +157,9 @@ class Profile(ActiveRecord): Python 3.11+ version using | syntax and Self type. """ - __table_name__ = "profiles" + @classmethod + def table_name(cls): + return "profiles" id: int | None = None bio: str diff --git a/tests/rhosocial/activerecord_test/feature/relation/fixtures/models_py312.py b/tests/rhosocial/activerecord_test/feature/relation/fixtures/models_py312.py index 84ce68c8d..75a3906f2 100644 --- a/tests/rhosocial/activerecord_test/feature/relation/fixtures/models_py312.py +++ b/tests/rhosocial/activerecord_test/feature/relation/fixtures/models_py312.py @@ -26,7 +26,9 @@ class Employee(ActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ - __table_name__ = "employees" + @classmethod + def table_name(cls): + return "employees" id: int | None = None username: str @@ -55,7 +57,9 @@ class Department(ActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ - __table_name__ = "departments" + @classmethod + def table_name(cls): + return "departments" id: int | None = None name: str @@ -84,7 +88,9 @@ class Author(ActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ - __table_name__ = "authors" + @classmethod + def table_name(cls): + return "authors" id: int | None = None name: str @@ -116,7 +122,9 @@ class Book(ActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ - __table_name__ = "books" + @classmethod + def table_name(cls): + return "books" id: int | None = None title: str @@ -149,7 +157,9 @@ class Chapter(ActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ - __table_name__ = "chapters" + @classmethod + def table_name(cls): + return "chapters" id: int | None = None title: str @@ -178,7 +188,9 @@ class Profile(ActiveRecord): Python 3.12+ version using | syntax, Self type, and @override. """ - __table_name__ = "profiles" + @classmethod + def table_name(cls): + return "profiles" id: int | None = None bio: str diff --git a/tests/rhosocial/activerecord_test/feature/relation/ordering/test_has_many_order_by_unit.py b/tests/rhosocial/activerecord_test/feature/relation/ordering/test_has_many_order_by_unit.py index 4b8495bf0..e64c10a19 100644 --- a/tests/rhosocial/activerecord_test/feature/relation/ordering/test_has_many_order_by_unit.py +++ b/tests/rhosocial/activerecord_test/feature/relation/ordering/test_has_many_order_by_unit.py @@ -45,7 +45,10 @@ class OrderingAuthor(ActiveRecord): """Author with an ordered HasMany whose sort field uses a custom column.""" - __table_name__ = "ordering_authors" + @classmethod + def table_name(cls): + return "ordering_authors" + c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -61,7 +64,10 @@ class OrderingAuthor(ActiveRecord): class OrderingBook(ActiveRecord): """Book whose Python field ``title`` maps to the column ``book_title``.""" - __table_name__ = "ordering_books" + @classmethod + def table_name(cls): + return "ordering_books" + id: Optional[int] = None title: Annotated[str, UseColumn("book_title")] author_id: int @@ -75,7 +81,10 @@ class OrderingBook(ActiveRecord): class PlainOrderingAuthor(ActiveRecord): """Author whose HasMany keeps the old un-ordered behaviour.""" - __table_name__ = "ordering_authors" + @classmethod + def table_name(cls): + return "ordering_authors" + id: Optional[int] = None name: str @@ -89,7 +98,10 @@ class PlainOrderingAuthor(ActiveRecord): class BadOrderAuthor(ActiveRecord): """Author with a relation that names a field that does not exist.""" - __table_name__ = "ordering_authors" + @classmethod + def table_name(cls): + return "ordering_authors" + id: Optional[int] = None name: str @@ -103,7 +115,10 @@ class BadOrderAuthor(ActiveRecord): class DerivedBook(ActiveRecord): """Book with a derived field that must not be accepted as an order key.""" - __table_name__ = "ordering_books" + @classmethod + def table_name(cls): + return "ordering_books" + id: Optional[int] = None title: str author_id: int @@ -121,7 +136,10 @@ class DerivedBook(ActiveRecord): class DerivedAuthor(ActiveRecord): """Author ordering by a derived field (rejected on first query build).""" - __table_name__ = "ordering_authors" + @classmethod + def table_name(cls): + return "ordering_authors" + id: Optional[int] = None name: str @@ -135,8 +153,13 @@ class DerivedAuthor(ActiveRecord): class OrderingPart(CompositePKMixin, ActiveRecord): """Composite-primary-key child used for tie-breaker checks.""" - __table_name__ = "ordering_parts" - __primary_key__ = ("order_id", "line_no") + @classmethod + def table_name(cls): + return "ordering_parts" + + @classmethod + def primary_key(cls): + return ("order_id", "line_no") order_id: int line_no: int @@ -147,7 +170,10 @@ class OrderingPart(CompositePKMixin, ActiveRecord): class OrderingOwner(ActiveRecord): """Owner of composite-key parts ordered by ``label``.""" - __table_name__ = "ordering_owners" + @classmethod + def table_name(cls): + return "ordering_owners" + id: Optional[int] = None name: str @@ -165,7 +191,10 @@ class OrderingOwner(ActiveRecord): class AsyncOrderingAuthor(AsyncActiveRecord): """Async mirror of OrderingAuthor.""" - __table_name__ = "ordering_authors" + @classmethod + def table_name(cls): + return "ordering_authors" + id: Optional[int] = None name: str @@ -179,7 +208,10 @@ class AsyncOrderingAuthor(AsyncActiveRecord): class AsyncOrderingBook(AsyncActiveRecord): """Async mirror of OrderingBook.""" - __table_name__ = "ordering_books" + @classmethod + def table_name(cls): + return "ordering_books" + id: Optional[int] = None title: str author_id: int diff --git a/tests/rhosocial/activerecord_test/feature/relation/test_type_resolver.py b/tests/rhosocial/activerecord_test/feature/relation/test_type_resolver.py index 8d77395fc..56d37c6ca 100644 --- a/tests/rhosocial/activerecord_test/feature/relation/test_type_resolver.py +++ b/tests/rhosocial/activerecord_test/feature/relation/test_type_resolver.py @@ -139,11 +139,17 @@ def test_sync_descriptor_target_must_be_sync_model(self): from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord class AsyncTarget(AsyncActiveRecord): - __table_name__ = "async_target_check" + @classmethod + def table_name(cls): + return "async_target_check" + id: Optional[int] = None class Owner(ActiveRecord): - __table_name__ = "owner_check" + @classmethod + def table_name(cls): + return "owner_check" + id: Optional[int] = None ref_id: int bad: ClassVar[BelongsTo["AsyncTarget"]] = BelongsTo(foreign_key="ref_id", inverse_of=None) @@ -159,11 +165,17 @@ def test_async_descriptor_target_must_be_async_model(self): from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord class SyncTarget(ActiveRecord): - __table_name__ = "sync_target_check" + @classmethod + def table_name(cls): + return "sync_target_check" + id: Optional[int] = None class Owner(AsyncActiveRecord): - __table_name__ = "owner_check2" + @classmethod + def table_name(cls): + return "owner_check2" + id: Optional[int] = None ref_id: int bad: ClassVar[AsyncBelongsTo["SyncTarget"]] = AsyncBelongsTo(foreign_key="ref_id", inverse_of=None) @@ -194,7 +206,10 @@ class PlainClass: from rhosocial.activerecord.model import AsyncActiveRecord class Owner(AsyncActiveRecord): - __table_name__ = "owner_check3" + @classmethod + def table_name(cls): + return "owner_check3" + id: Optional[int] = None ref_id: int rel: ClassVar[AsyncBelongsTo["PlainClass"]] = AsyncBelongsTo(foreign_key="ref_id", inverse_of=None) @@ -207,11 +222,17 @@ def test_sync_descriptor_target_is_sync_model_passes(self): from rhosocial.activerecord.model import ActiveRecord class SyncTarget(ActiveRecord): - __table_name__ = "sync_target3" + @classmethod + def table_name(cls): + return "sync_target3" + id: Optional[int] = None class Owner(ActiveRecord): - __table_name__ = "owner_check4" + @classmethod + def table_name(cls): + return "owner_check4" + id: Optional[int] = None ref_id: int good: ClassVar[BelongsTo["SyncTarget"]] = BelongsTo(foreign_key="ref_id", inverse_of=None) @@ -226,11 +247,17 @@ def test_async_descriptor_target_is_async_model_passes(self): from rhosocial.activerecord.model import AsyncActiveRecord class AsyncTarget(AsyncActiveRecord): - __table_name__ = "async_target3" + @classmethod + def table_name(cls): + return "async_target3" + id: Optional[int] = None class Owner(AsyncActiveRecord): - __table_name__ = "owner_check5" + @classmethod + def table_name(cls): + return "owner_check5" + id: Optional[int] = None ref_id: int good: ClassVar[AsyncBelongsTo["AsyncTarget"]] = AsyncBelongsTo(foreign_key="ref_id", inverse_of=None) diff --git a/tests/rhosocial/activerecord_test/logging_module/test_crud_summarization.py b/tests/rhosocial/activerecord_test/logging_module/test_crud_summarization.py index 7cefc26e3..f31a3bb0a 100644 --- a/tests/rhosocial/activerecord_test/logging_module/test_crud_summarization.py +++ b/tests/rhosocial/activerecord_test/logging_module/test_crud_summarization.py @@ -27,7 +27,9 @@ class Article(ActiveRecord): """Test model with long text fields for summarization testing.""" - __table_name__ = "articles" + @classmethod + def table_name(cls): + return "articles" id: Optional[int] = None title: str = "" diff --git a/tests/rhosocial/activerecord_test/logging_module/test_execution_logging.py b/tests/rhosocial/activerecord_test/logging_module/test_execution_logging.py index 5fcd7d52d..fcf4cad78 100644 --- a/tests/rhosocial/activerecord_test/logging_module/test_execution_logging.py +++ b/tests/rhosocial/activerecord_test/logging_module/test_execution_logging.py @@ -54,7 +54,10 @@ def log_model(tmp_path): """Configure an isolated SQLite model with a fresh table.""" class LogUser(ActiveRecord): - __table_name__ = "log_users" + @classmethod + def table_name(cls): + return "log_users" + id: Optional[int] = None name: str = "" secret: str = "" @@ -325,7 +328,9 @@ async def test_async_execution_logs_same_events(tmp_path, caplog): class LogUserRef: """Stand-in model used where only the class name matters.""" - __table_name__ = "log_users" + @classmethod + def table_name(cls): + return "log_users" def test_sync_and_async_backend_share_logger_name(): diff --git a/tests/rhosocial/activerecord_test/logging_module/test_model_logging_config.py b/tests/rhosocial/activerecord_test/logging_module/test_model_logging_config.py index b87a97e24..2af16247b 100644 --- a/tests/rhosocial/activerecord_test/logging_module/test_model_logging_config.py +++ b/tests/rhosocial/activerecord_test/logging_module/test_model_logging_config.py @@ -33,7 +33,10 @@ def reset_logging_state(self): def test_model_logging_config_overrides_global_default_for_log_data(self): class LocalModel(ActiveRecord): - __table_name__ = "local_model" + @classmethod + def table_name(cls): + return "local_model" + id: Optional[int] = None stream = io.StringIO() @@ -63,11 +66,17 @@ class LocalModel(ActiveRecord): def test_different_models_use_different_logging_configs(self): class HiddenModel(ActiveRecord): - __table_name__ = "hidden_model" + @classmethod + def table_name(cls): + return "hidden_model" + id: Optional[int] = None class FullModel(ActiveRecord): - __table_name__ = "full_model" + @classmethod + def table_name(cls): + return "full_model" + id: Optional[int] = None hidden_original = HiddenModel.__logging_config__ @@ -86,7 +95,10 @@ class FullModel(ActiveRecord): def test_configure_binds_logging_config_to_model_and_backend(self): class ConfiguredModel(ActiveRecord): - __table_name__ = "configured_model" + @classmethod + def table_name(cls): + return "configured_model" + id: Optional[int] = None logging_config = LoggingConfig(log_data_mode=LogDataMode.KEYS_ONLY) @@ -109,7 +121,10 @@ class ConfiguredModel(ActiveRecord): @pytest.mark.asyncio async def test_async_configure_binds_logging_config_to_model_and_backend(self): class AsyncConfiguredModel(AsyncActiveRecord): - __table_name__ = "async_configured_model" + @classmethod + def table_name(cls): + return "async_configured_model" + id: Optional[int] = None logging_config = LoggingConfig(log_data_mode=LogDataMode.KEYS_ONLY) @@ -131,11 +146,17 @@ class AsyncConfiguredModel(AsyncActiveRecord): def test_backend_group_binds_logging_config_to_models_and_backend(self): class GroupUser(ActiveRecord): - __table_name__ = "group_users" + @classmethod + def table_name(cls): + return "group_users" + id: Optional[int] = None class GroupPost(ActiveRecord): - __table_name__ = "group_posts" + @classmethod + def table_name(cls): + return "group_posts" + id: Optional[int] = None logging_config = LoggingConfig(log_data_mode=LogDataMode.HIDDEN) @@ -160,7 +181,10 @@ class GroupPost(ActiveRecord): def test_backend_group_without_logging_config_keeps_model_config(self): class GroupKeepModel(ActiveRecord): - __table_name__ = "group_keep_model" + @classmethod + def table_name(cls): + return "group_keep_model" + id: Optional[int] = None model_config = LoggingConfig(log_data_mode=LogDataMode.FULL) @@ -184,7 +208,10 @@ class GroupKeepModel(ActiveRecord): @pytest.mark.asyncio async def test_async_backend_group_binds_logging_config_to_models_and_backend(self): class AsyncGroupUser(AsyncActiveRecord): - __table_name__ = "async_group_users" + @classmethod + def table_name(cls): + return "async_group_users" + id: Optional[int] = None logging_config = LoggingConfig(log_data_mode=LogDataMode.HIDDEN) @@ -213,11 +240,17 @@ def test_subclasses_get_own_logging_config_instance(self): """Different model subclasses should have independent __logging_config__ instances.""" class UserModel(ActiveRecord): - __table_name__ = "users" + @classmethod + def table_name(cls): + return "users" + id: Optional[int] = None class PostModel(ActiveRecord): - __table_name__ = "posts" + @classmethod + def table_name(cls): + return "posts" + id: Optional[int] = None assert UserModel.__logging_config__ is not PostModel.__logging_config__ @@ -226,11 +259,17 @@ def test_mutating_one_config_does_not_affect_another(self): """Mutating one model's logging config should not affect another model.""" class AlphaModel(ActiveRecord): - __table_name__ = "alpha" + @classmethod + def table_name(cls): + return "alpha" + id: Optional[int] = None class BetaModel(ActiveRecord): - __table_name__ = "beta" + @classmethod + def table_name(cls): + return "beta" + id: Optional[int] = None AlphaModel.__logging_config__.default_level = logging.ERROR @@ -242,7 +281,10 @@ def test_explicit_config_override_preserved(self): custom_config = LoggingConfig(default_level=logging.CRITICAL) class CustomModel(ActiveRecord): - __table_name__ = "custom" + @classmethod + def table_name(cls): + return "custom" + id: Optional[int] = None __logging_config__ = custom_config @@ -253,11 +295,17 @@ def test_summarizer_config_mutation_isolation(self): """Mutating one model's summarizer_config should not affect another model.""" class ModelA(ActiveRecord): - __table_name__ = "model_a" + @classmethod + def table_name(cls): + return "model_a" + id: Optional[int] = None class ModelB(ActiveRecord): - __table_name__ = "model_b" + @classmethod + def table_name(cls): + return "model_b" + id: Optional[int] = None ModelA.__logging_config__.summarizer_config.max_string_length = 10 From 82e157e19f3d9d11724be7e6ebc68db8a7cc4fdd Mon Sep 17 00:00:00 2001 From: zhao vistart Date: Thu, 12 Nov 2026 13:56:09 +0800 Subject: [PATCH 2/2] chore: add changelog fragment for PR #140 --- changelog.d/140.added.md | 1 + changelog.d/140.removed.md | 1 + 2 files changed, 2 insertions(+) create mode 100644 changelog.d/140.added.md create mode 100644 changelog.d/140.removed.md diff --git a/changelog.d/140.added.md b/changelog.d/140.added.md new file mode 100644 index 000000000..deb5d8186 --- /dev/null +++ b/changelog.d/140.added.md @@ -0,0 +1 @@ +Added composite index and constraint declarations on the field-level annotation surface: `UseIndex` accepts an authoritative `columns` list whose first entry must be the annotated field's own column, and `UseConstraint` gained the multi-column form for branches with a table-level counterpart (composite UNIQUE / FOREIGN KEY). Added `UsePartitionKey()` for partition keys, extracted onto `__partition_key__` in field order with a `TypeError` when a direct declaration also exists, a `UserWarning` when the key is not contained in the primary key, and a query-time warning plus one CLI stderr line (suppressed by `--quiet`) when a lookup constrains none of the partition columns. Added `uuid_version()` so a model may select time-ordered `uuid7` keys (probed, not imported, since the standard library gained it in 3.14), `natural_key()` so `find_one_or_create` can take its key members from `defaults`, and `reload()` (sync and async) as the other half of the stale-record recipe. Added `.claude/rules/declaration-surfaces.md`, the single statement of what a model may declare on, and rewrote every declarative example in the documentation to the accessor form. diff --git a/changelog.d/140.removed.md b/changelog.d/140.removed.md new file mode 100644 index 000000000..0e119b584 --- /dev/null +++ b/changelog.d/140.removed.md @@ -0,0 +1 @@ +**Breaking:** model declarations moved from class-body dunders onto overridable classmethod accessors. `__table_name__`, `__schema_name__`, `__primary_key__`, `__pk_auto_generated__`, the soft-delete/timestamp/version locators (`__deleted_at_field__`, `__created_at_field__`, `__updated_at_field__`, `__version_field__`, `__version_increment_by__`) and `__read_only__` no longer declare anything: declare by overriding `table_name()`, `schema_name()`, `primary_key()`, `pk_auto_generated()`, the matching locator method or `read_only()`. `table_name()` has no default, so a model that declares no table fails with `NotImplementedError` at the first read instead of carrying a `None` name into a statement. Accessor defaults read no dunder at all, and the declaration dunders were removed, so there is no compatibility path: this is a pre-1.0 migration made once and completely. `__partition_key__` remains a valid direct declaration for introspected models, mutually exclusive with `UsePartitionKey()` annotations.