Skip to content

Expose read-only ManyToMany SQL metadata#134

Merged
seapagan merged 14 commits intomainfrom
issue-133-m2m-sql-metadata
Feb 21, 2026
Merged

Expose read-only ManyToMany SQL metadata#134
seapagan merged 14 commits intomainfrom
issue-133-m2m-sql-metadata

Conversation

@seapagan
Copy link
Owner

@seapagan seapagan commented Feb 19, 2026

Summary

  • add public read-only M2M SQL metadata via M2MSQLMetadata
  • expose sql_metadata on forward descriptor, reverse descriptor, manager, and prefetched wrapper
  • add exhaustive tests for forward/reverse/manager paths and edge cases
  • update API/docs and add a TUI ORM demo for metadata introspection
  • simplify the TUI demo/docs usage to mypy-friendly forward access patterns
  • add a medium-term TODO to explore explicit reverse declarations for strict typing

Closes #133

Summary by CodeRabbit

  • New Features

    • Read-only SQL metadata introspection for many-to-many relationships exposed on descriptors, managers, reverse accessors and prefetched results (junction table, column/table mappings, symmetry).
    • New public M2MSQLMetadata type for structured metadata access.
  • Documentation

    • Added API reference, guide and demo docs with examples showing how to inspect M2M SQL metadata.
  • TUI Demo

    • New interactive demo showcasing M2M SQL metadata introspection.
  • Tests

    • Comprehensive tests for metadata behaviour, self-references, prefetched scenarios and error handling.

@codacy-production
Copy link

codacy-production bot commented Feb 19, 2026

Coverage summary from Codacy

See diff coverage on Codacy

Coverage variation Diff coverage
+0.00% (target: -1.00%) 100.00%
Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (e0aceeb) 4236 4236 100.00%
Head commit (4aa9c02) 4309 (+73) 4309 (+73) 100.00% (+0.00%)

Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch: <coverage of head commit> - <coverage of common ancestor commit>

Diff coverage details
Coverable lines Covered lines Diff coverage
Pull request (#134) 83 83 100.00%

Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: <covered lines added or modified>/<coverable lines added or modified> * 100%

See your quality gate settings    Change summary preferences

@seapagan seapagan self-assigned this Feb 19, 2026
@seapagan seapagan added the enhancement New feature or request label Feb 19, 2026
@coderabbitai
Copy link

coderabbitai bot commented Feb 19, 2026

Important

Review skipped

This PR was authored by the user configured for CodeRabbit reviews. By default, CodeRabbit skips reviewing PRs authored by this user. It's recommended to use a dedicated user account to post CodeRabbit review feedback.

To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • ✅ Review completed - (🔄 Check again to review again)
📝 Walkthrough

Walkthrough

Adds read‑only ManyToMany SQL metadata: introduces M2MSQLMetadata and sql_metadata properties on ManyToMany descriptors, managers, prefetched results and reverse accessors; updates M2M column-name logic and self-referential handling; includes tests, docs, demo changes, and a TODO note about explicit reverse typing.

Changes

Cohort / File(s) Summary
Core M2M SQL Metadata
sqliter/orm/m2m.py
Add M2MSQLMetadata dataclass and _build_m2m_sql_metadata; compute/cache _sql_metadata; derive _from_col/_to_col from metadata; add sql_metadata properties to ManyToMany, ManyToManyManager, PrefetchedM2MResult, and ReverseManyToMany; adjust column-name generation and self‑referential orientation handling.
Tests
tests/test_m2m.py, tests/test_prefetch_related.py
Export M2MSQLMetadata; add TestManyToManySQLMetadata covering forward/reverse, unresolved forward refs, custom through tables, self‑referential cases and immutability; add prefetched-manager metadata test.
Documentation
docs/api-reference/many-to-many.md, docs/guide/many-to-many.md
Document M2MSQLMetadata structure and add "SQL Metadata Introspection" examples for descriptor, manager and reverse accessors.
Demo & TUI
sqliter/tui/demos/orm.py, docs/tui-demo/orm.md
Add demo _run_many_to_many_sql_metadata() and demo registry entry; insert CLI/docs demo sections showing metadata output (duplicated block observed in docs).
Roadmap note
TODO.md
Add medium‑term typing direction suggesting explicit reverse relationship declarations in model classes for mypy‑friendly reverse‑side typing.

Sequence Diagram(s)

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped through junctions, counted every name,
I found the columns, gave each one a frame.
From descriptor to manager, reverse and tree,
I carried metadata home — a rabbit’s glee. 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarises the main change: exposing read-only SQL metadata for ManyToMany relationships.
Linked Issues check ✅ Passed The PR fully implements all coding requirements from issue #133: exposes read-only M2MSQLMetadata with junction_table, from_column, to_column, source_table, target_table, and symmetrical fields across all M2M surfaces (descriptor, reverse descriptor, manager, prefetched result).
Out of Scope Changes check ✅ Passed All changes are in scope: core M2M metadata implementation, API exposure, comprehensive tests, documentation updates, and a TUI demo. The TODO.md addition about future explicit reverse declarations is a reasonable medium-term planning note.
Docstring Coverage ✅ Passed Docstring coverage is 97.73% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@seapagan seapagan marked this pull request as ready for review February 20, 2026 21:02
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
sqliter/orm/m2m.py (1)

163-172: _from_col/_to_col are computed twice — consider deriving from the metadata object

The column names are first computed manually (lines 163–165) and then re-derived by the same _m2m_column_names + swap logic inside _build_m2m_sql_metadata (lines 166–172). This is harmless but the two results must always stay in sync.

Reversing the order (compute metadata first, then read columns from it) removes the duplication:

♻️ Proposed refactor
-        self._from_col, self._to_col = _m2m_column_names(from_table, to_table)
-        if manager_options.swap_columns:
-            self._from_col, self._to_col = self._to_col, self._from_col
-        self._sql_metadata = _build_m2m_sql_metadata(
+        self._sql_metadata = _build_m2m_sql_metadata(
             source_table=from_table,
             target_table=to_table,
             junction_table=junction_table,
             symmetrical=manager_options.symmetrical,
             swap_columns=manager_options.swap_columns,
         )
+        self._from_col = self._sql_metadata.from_column
+        self._to_col = self._sql_metadata.to_column
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sqliter/orm/m2m.py` around lines 163 - 172, The code computes
_from_col/_to_col twice—once via _m2m_column_names and again inside
_build_m2m_sql_metadata—so refactor to call _build_m2m_sql_metadata first to
produce _sql_metadata and then set self._from_col and self._to_col from that
metadata (instead of re-calling _m2m_column_names or re-applying
manager_options.swap_columns). Update the constructor/initializer to remove the
initial _m2m_column_names call and derive column names from the returned
_sql_metadata fields to keep a single source of truth (referencing
_build_m2m_sql_metadata, _sql_metadata, _from_col, _to_col, and
manager_options.swap_columns).
docs/guide/many-to-many.md (1)

96-109: Prefer Article.tags over Article.__dict__["tags"] for consistency

Both produce the same ManyToMany descriptor object (since __get__ with instance=None returns self), but Article.__dict__["tags"] bypasses the descriptor protocol and is unusual in user-facing docs. The equivalent section in docs/tui-demo/orm.md uses Article.tags.sql_metadata directly, which is cleaner and more consistent.

♻️ Proposed fix
-desc = Article.__dict__["tags"]
-meta = desc.sql_metadata
-assert meta is not None
+meta = Article.tags.sql_metadata
+assert meta is not None
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/guide/many-to-many.md` around lines 96 - 109, The snippet uses
Article.__dict__["tags"] which bypasses the descriptor protocol and is
inconsistent with other docs; change it to use the descriptor directly
(Article.tags) when obtaining sql_metadata (e.g., replace desc =
Article.__dict__["tags"] with desc = Article.tags) and then continue using
desc.sql_metadata (or meta = Article.tags.sql_metadata) so the example is
consistent and clearer.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/tui-demo/orm.md`:
- Around line 403-406: The sentence under the "## M2M SQL Metadata
Introspection" heading should include a comma before the coordinating
conjunction "so"; update the line "Inspect read-only SQL metadata for
many-to-many relationships so raw SQL can reuse SQLiter's naming conventions."
to read with a comma before "so" to improve readability.

---

Nitpick comments:
In `@docs/guide/many-to-many.md`:
- Around line 96-109: The snippet uses Article.__dict__["tags"] which bypasses
the descriptor protocol and is inconsistent with other docs; change it to use
the descriptor directly (Article.tags) when obtaining sql_metadata (e.g.,
replace desc = Article.__dict__["tags"] with desc = Article.tags) and then
continue using desc.sql_metadata (or meta = Article.tags.sql_metadata) so the
example is consistent and clearer.

In `@sqliter/orm/m2m.py`:
- Around line 163-172: The code computes _from_col/_to_col twice—once via
_m2m_column_names and again inside _build_m2m_sql_metadata—so refactor to call
_build_m2m_sql_metadata first to produce _sql_metadata and then set
self._from_col and self._to_col from that metadata (instead of re-calling
_m2m_column_names or re-applying manager_options.swap_columns). Update the
constructor/initializer to remove the initial _m2m_column_names call and derive
column names from the returned _sql_metadata fields to keep a single source of
truth (referencing _build_m2m_sql_metadata, _sql_metadata, _from_col, _to_col,
and manager_options.swap_columns).

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
sqliter/orm/m2m.py (2)

162-171: self._symmetrical is now a second source of truth.

self._symmetrical (line 162) always equals self._sql_metadata.symmetrical — both resolve to bool(manager_options.symmetrical and self._self_ref). The five methods that use self._symmetrical (_fetch_related_pks, add, remove, clear, count) could instead reference self._sql_metadata.symmetrical, eliminating the risk of these diverging in a future change.

♻️ Suggested cleanup
-        self._self_ref = from_table == to_table
-        self._symmetrical = bool(manager_options.symmetrical and self._self_ref)
         self._sql_metadata = _build_m2m_sql_metadata(
             source_table=from_table,
             target_table=to_table,
             junction_table=junction_table,
             symmetrical=manager_options.symmetrical,
             swap_columns=manager_options.swap_columns,
         )
         self._from_col = self._sql_metadata.from_column
         self._to_col = self._sql_metadata.to_column
+        self._symmetrical = self._sql_metadata.symmetrical

self._self_ref can also be dropped since nothing outside __init__ uses it after this refactor.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sqliter/orm/m2m.py` around lines 162 - 171, self._symmetrical is redundant
with self._sql_metadata.symmetrical; update the class to stop storing a second
source of truth by removing the self._symmetrical assignment in __init__ and
replacing all uses of self._symmetrical in _fetch_related_pks, add, remove,
clear, and count with self._sql_metadata.symmetrical; also remove self._self_ref
from the instance if it becomes unused after this change (and drop any code that
only existed to set it in __init__).

680-699: ManyToMany.sql_metadata recomputes on every access.

Both get_table_name() calls and the M2MSQLMetadata construction run on each property access. Since owner, to_model, and _junction_table are all stable once the descriptor is registered, the result could be cached as _sql_metadata on the descriptor the first time it's computed (guard: _junction_table is not None). The same applies to ReverseManyToMany.sql_metadata (lines 869–884), where all inputs are set in __init__ and never change.

This is negligible for infrequent introspection but could accumulate if called in a hot path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sqliter/orm/m2m.py` around lines 680 - 699, The sql_metadata property on
ManyToMany (and similarly on ReverseManyToMany) recomputes each access; change
it to memoize the result in a private attribute (e.g., _sql_metadata): if owner
is None or to_model is a str return None as before, otherwise if _sql_metadata
is None and _junction_table is not None compute owner_table/target_table via
get_table_name(), call _build_m2m_sql_metadata with source_table, target_table,
junction_table and m2m_info.symmetrical, store the returned M2MSQLMetadata in
self._sql_metadata and return it; ensure ReverseManyToMany.sql_metadata uses the
same caching (its inputs are fixed in __init__), and keep the existing guards
around _junction_table.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@sqliter/orm/m2m.py`:
- Around line 162-171: self._symmetrical is redundant with
self._sql_metadata.symmetrical; update the class to stop storing a second source
of truth by removing the self._symmetrical assignment in __init__ and replacing
all uses of self._symmetrical in _fetch_related_pks, add, remove, clear, and
count with self._sql_metadata.symmetrical; also remove self._self_ref from the
instance if it becomes unused after this change (and drop any code that only
existed to set it in __init__).
- Around line 680-699: The sql_metadata property on ManyToMany (and similarly on
ReverseManyToMany) recomputes each access; change it to memoize the result in a
private attribute (e.g., _sql_metadata): if owner is None or to_model is a str
return None as before, otherwise if _sql_metadata is None and _junction_table is
not None compute owner_table/target_table via get_table_name(), call
_build_m2m_sql_metadata with source_table, target_table, junction_table and
m2m_info.symmetrical, store the returned M2MSQLMetadata in self._sql_metadata
and return it; ensure ReverseManyToMany.sql_metadata uses the same caching (its
inputs are fixed in __init__), and keep the existing guards around
_junction_table.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
sqliter/orm/m2m.py (1)

679-701: Memoisation of descriptor-level metadata — note the interaction with resolve_forward_ref.

The lazy-init + cache pattern is correct: the guards at lines 686-689 ensure None is returned (without caching) while the descriptor is still partially resolved, and once the metadata is built it is cached for all future accesses.

One thing to be aware of: resolve_forward_ref (line 667) mutates to_model and _junction_table but does not clear _sql_metadata. This is fine today because _sql_metadata can only be non-None after both fields are resolved, so re-resolution would never encounter a stale cache. However, if resolve_forward_ref is ever called a second time with a different target, the stale cache would silently persist. A defensive one-liner (self._sql_metadata = None) inside resolve_forward_ref would close this off cheaply.

Optional defensive reset in resolve_forward_ref
     def resolve_forward_ref(self, model_class: type[Any]) -> None:
         """Resolve a string forward ref to a concrete model class."""
         self.to_model = model_class
         self.m2m_info.to_model = model_class
+        self._sql_metadata = None          # invalidate cached metadata
         if self._junction_table is None and self.owner is not None:
             self._junction_table = self._get_junction_table_name(self.owner)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sqliter/orm/m2m.py` around lines 679 - 701, The cached SQL metadata stored on
the descriptor (_sql_metadata) can become stale if resolve_forward_ref mutates
to_model or _junction_table; update resolve_forward_ref to defensively reset the
cache by setting self._sql_metadata = None whenever it updates to_model or
_junction_table so subsequent accesses to the sql_metadata property rebuild
fresh metadata.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@sqliter/orm/m2m.py`:
- Line 633: The import of FrozenInstanceError should be made conditional to
preserve Python 3.9–3.10 compatibility: in the module that currently does "from
dataclasses import FrozenInstanceError" (e.g., tests referencing
FrozenInstanceError) check sys.version_info and import it only for >= (3,11),
otherwise assign a compatible fallback (e.g., FrozenInstanceError = Exception or
a small custom subclass of Exception); update the import site that references
FrozenInstanceError so all uses (tests or code) reference this conditional
symbol instead of assuming dataclasses provides it.

---

Nitpick comments:
In `@sqliter/orm/m2m.py`:
- Around line 679-701: The cached SQL metadata stored on the descriptor
(_sql_metadata) can become stale if resolve_forward_ref mutates to_model or
_junction_table; update resolve_forward_ref to defensively reset the cache by
setting self._sql_metadata = None whenever it updates to_model or
_junction_table so subsequent accesses to the sql_metadata property rebuild
fresh metadata.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a public, read-only API for introspecting many-to-many SQL naming (junction table and FK column names) so application code can build occasional raw SQL without duplicating internal conventions.

Changes:

  • Introduces M2MSQLMetadata and exposes sql_metadata on forward descriptors, reverse descriptors, instance managers, and prefetched M2M results.
  • Updates TUI ORM demo and docs to showcase metadata introspection.
  • Adds comprehensive tests covering forward/reverse/manager/prefetch paths, self-references, caching, and unresolved forward refs.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
sqliter/orm/m2m.py Implements M2MSQLMetadata, builder helper, and sql_metadata properties across descriptor/manager/prefetch wrappers.
tests/test_m2m.py Adds a new test suite validating metadata values, caching, immutability, and forward-ref edge cases.
tests/test_prefetch_related.py Verifies prefetched M2M wrapper exposes sql_metadata via manager passthrough.
sqliter/tui/demos/orm.py Adds a new demo for inspecting M2M SQL metadata in the TUI.
docs/tui-demo/orm.md Documents the new TUI demo usage for M2M SQL metadata introspection.
docs/guide/many-to-many.md Adds a guide section demonstrating using sql_metadata for raw SQL queries.
docs/api-reference/many-to-many.md Documents the new sql_metadata surface area and the M2MSQLMetadata type.
TODO.md Notes a medium-term typing direction for explicit reverse relationship declarations.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

seapagan and others added 2 commits February 21, 2026 06:56
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Grant Ramsay <seapagan@gmail.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@sqliter/orm/m2m.py`:
- Line 158: Shorten the overly-long inline comment in sqliter/orm/m2m.py that
currently reads about "Column/metadata names derived from from_model/to_model
table names (with optional column swapping)" so it is under 80 characters;
locate the comment just above the logic that builds column/metadata names for
the many-to-many mapping (the block that refers to from_model/to_model table
names) and replace it with a concise version such as "Column/metadata names
derived from model table names (columns may swap)" to satisfy the E501 lint
rule.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
sqliter/tui/demos/orm.py (2)

289-293: Article.tags.sql_metadata can return None; the cast masks this.

ManyToMany.sql_metadata returns Optional[M2MSQLMetadata]. Using cast here suppresses the type checker but doesn't guard against None at runtime. In this demo both models are concrete, so it's safe in practice, but an explicit guard would be more robust and would better illustrate the API for users reading the demo code.

Suggested improvement
-    descriptor_meta = cast("M2MSQLMetadata", Article.tags.sql_metadata)
-    output.write("Descriptor metadata (Article.tags):\n")
+    descriptor_meta = Article.tags.sql_metadata
+    if descriptor_meta is None:
+        output.write("Descriptor metadata not available\n")
+        db.close()
+        return output.getvalue()
+    output.write("Descriptor metadata (Article.tags):\n")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sqliter/tui/demos/orm.py` around lines 289 - 293, Article.tags.sql_metadata
is Optional[M2MSQLMetadata] and the current cast hides potential None; update
the demo to check for None before accessing descriptor_meta attributes: retrieve
descriptor_meta from Article.tags.sql_metadata, if it's None write a clear
message to output (e.g., "no SQL metadata available") or raise a helpful error,
otherwise access descriptor_meta.junction_table, .from_column and .to_column;
reference the symbols Article.tags.sql_metadata, M2MSQLMetadata and the
descriptor_meta variable when making the change so the guard is applied before
any attribute access.

6-13: Static analysis: Any import flagged as unused.

Codacy reports Any is unused. The cast("Any", ...) calls throughout the file use a string literal, so the symbol isn't technically referenced at runtime. Since TYPE_CHECKING was just added to this line, it's likely a pre-existing issue surfaced by the line change.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sqliter/tui/demos/orm.py` around lines 6 - 13, Update the typing usage so the
Any import is actually referenced: in sqliter/tui/demos/orm.py change the
string-literal casts like cast("Any", ...) to use the real Any symbol (cast(Any,
...)) or remove the unused Any import and keep the string casts; specifically
edit the import line (from typing import TYPE_CHECKING, Any, Optional, cast) and
all cast("Any", ...) occurrences so the Any symbol is used (or remove Any if you
prefer the string-cast approach) to satisfy static analysis.
sqliter/orm/m2m.py (1)

872-893: Duplicated swap_columns condition between sql_metadata and __get__.

The expression self._from_model is self._to_model and not self._symmetrical appears identically on lines 890–891 and 937–938. If one is updated without the other, column orientation would silently diverge between the metadata and the manager.

Consider extracting it to a cached property or private method:

Suggested refactor
+    `@property`
+    def _swap_columns(self) -> bool:
+        """Whether columns must be swapped for reverse orientation."""
+        return self._from_model is self._to_model and not self._symmetrical
+
     `@property`
     def sql_metadata(self) -> M2MSQLMetadata:
         ...
         self._sql_metadata = _build_m2m_sql_metadata(
             ...
-            swap_columns=self._from_model is self._to_model
-            and not self._symmetrical,
+            swap_columns=self._swap_columns,
         )
         ...

     def __get__(self, ...):
         ...
         options=ManyToManyOptions(
             symmetrical=self._symmetrical,
-            swap_columns=self._from_model is self._to_model
-            and not self._symmetrical,
+            swap_columns=self._swap_columns,
         ),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@sqliter/orm/m2m.py` around lines 872 - 893, The duplicated boolean expression
"self._from_model is self._to_model and not self._symmetrical" used in the
sql_metadata property and __get__ should be extracted to a single source of
truth: add a private cached property or method (e.g. _should_swap_columns or
swap_columns_cached) on the same class, compute and cache the expression there,
then replace the inline expressions in sql_metadata (where
_build_m2m_sql_metadata is called) and in __get__ to reference that new
property/method so both places use the identical, single computed value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@sqliter/orm/m2m.py`:
- Around line 872-893: The duplicated boolean expression "self._from_model is
self._to_model and not self._symmetrical" used in the sql_metadata property and
__get__ should be extracted to a single source of truth: add a private cached
property or method (e.g. _should_swap_columns or swap_columns_cached) on the
same class, compute and cache the expression there, then replace the inline
expressions in sql_metadata (where _build_m2m_sql_metadata is called) and in
__get__ to reference that new property/method so both places use the identical,
single computed value.

In `@sqliter/tui/demos/orm.py`:
- Around line 289-293: Article.tags.sql_metadata is Optional[M2MSQLMetadata] and
the current cast hides potential None; update the demo to check for None before
accessing descriptor_meta attributes: retrieve descriptor_meta from
Article.tags.sql_metadata, if it's None write a clear message to output (e.g.,
"no SQL metadata available") or raise a helpful error, otherwise access
descriptor_meta.junction_table, .from_column and .to_column; reference the
symbols Article.tags.sql_metadata, M2MSQLMetadata and the descriptor_meta
variable when making the change so the guard is applied before any attribute
access.
- Around line 6-13: Update the typing usage so the Any import is actually
referenced: in sqliter/tui/demos/orm.py change the string-literal casts like
cast("Any", ...) to use the real Any symbol (cast(Any, ...)) or remove the
unused Any import and keep the string casts; specifically edit the import line
(from typing import TYPE_CHECKING, Any, Optional, cast) and all cast("Any", ...)
occurrences so the Any symbol is used (or remove Any if you prefer the
string-cast approach) to satisfy static analysis.

@seapagan seapagan merged commit 21f6852 into main Feb 21, 2026
15 checks passed
@seapagan seapagan deleted the issue-133-m2m-sql-metadata branch February 21, 2026 07:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Expose read-only ManyToMany SQL metadata (junction/column names)

2 participants