Repository navigation
feat(segments): P1/4 — the segment table, create and read a static list #1345
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| """Mechanical DB access for crm_segment (T15) — one table, one file (module | ||
| rules §1 at scale). Every function self-scopes one statement; no decisions. | ||
| """ | ||
|
|
||
| from typing import List, Optional | ||
|
|
||
| from app.crm.outreach.db.decoders.segment import decode_segment | ||
| from app.crm.outreach.db.queries.segment import ( | ||
| count_segments_query, | ||
| get_segment_query, | ||
| insert_static_list_query, | ||
| list_segments_query, | ||
| ) | ||
| from app.crm.outreach.schemas import Segment | ||
| from app.crm.shared.db import crm_connection | ||
|
|
||
|
|
||
| async def insert_static_list( | ||
| merchant_id: str, name: str, created_by: Optional[str] | ||
| ) -> Segment: | ||
| """Raises the db door's UniqueViolation when the merchant already has a | ||
| segment with this name — the caller says what that means.""" | ||
| query, values = insert_static_list_query(merchant_id, name, created_by) | ||
| async with crm_connection() as conn: | ||
| row = await conn.fetchrow(query, *values) | ||
| assert row is not None # INSERT ... RETURNING always yields the row | ||
| return decode_segment(row) | ||
|
|
||
|
|
||
| async def get_segment(merchant_id: str, segment_id: str) -> Optional[Segment]: | ||
| query, values = get_segment_query(merchant_id, segment_id) | ||
| async with crm_connection() as conn: | ||
| row = await conn.fetchrow(query, *values) | ||
| return decode_segment(row) if row else None | ||
|
|
||
|
|
||
| async def list_segments(merchant_id: str, limit: int, offset: int) -> List[Segment]: | ||
| query, values = list_segments_query(merchant_id, limit, offset) | ||
| async with crm_connection() as conn: | ||
| rows = await conn.fetch(query, *values) | ||
| return [decode_segment(row) for row in rows] | ||
|
|
||
|
|
||
| async def count_segments(merchant_id: str) -> int: | ||
| query, values = count_segments_query(merchant_id) | ||
| async with crm_connection() as conn: | ||
| row = await conn.fetchrow(query, *values) | ||
| return int(row["total"]) if row else 0 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| """row -> schema translation for crm_segment (T15) — one table, one file | ||
| (module rules §1 at scale). DB-side translation only — never imported | ||
| outside db/. | ||
| """ | ||
|
|
||
| from typing import Any, Mapping | ||
|
|
||
| from app.crm.outreach.schemas import Segment | ||
| from app.crm.shared.decode import jsonb_value | ||
|
|
||
|
|
||
| def decode_segment(row: Mapping[str, Any]) -> Segment: | ||
| definition = jsonb_value(row["definition"]) | ||
| return Segment( | ||
| id=row["id"], | ||
| merchant_id=row["merchant_id"], | ||
| name=row["name"], | ||
| # The kind is a predicate over definition, never a stored column | ||
| # (law 10): NULL = a static list whose members are rows. | ||
| kind="static" if definition is None else "dynamic", | ||
| definition=definition, | ||
| member_count=row["member_count"], | ||
| computed_at=row["computed_at"], | ||
| created_by=row["created_by"], | ||
| created_at=row["created_at"], | ||
| updated_at=row["updated_at"], | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| """SQL builders for crm_segment (T15, canon/05-audiences.md) — one table, one | ||
| file (module rules §1 at scale). $1 placeholders only — every value | ||
| parameterized; merchant_id is $1 on every read and write (tenancy law). | ||
| """ | ||
|
|
||
| from typing import Any, List, Optional, Tuple | ||
|
|
||
| from app.crm.outreach.db.queries.tables import SEGMENT_TABLE | ||
|
|
||
| _SEGMENT_COLUMNS = """ | ||
| id, merchant_id, name, definition, member_count, computed_at, | ||
| created_by, created_at, updated_at | ||
| """ | ||
|
|
||
|
|
||
| def insert_static_list_query( | ||
| merchant_id: str, name: str, created_by: Optional[str] | ||
| ) -> Tuple[str, List[Any]]: | ||
| """A static list is born empty, and its count is exact from the first | ||
| moment: zero members, stamped now (T15 col 7 — exact forever for a | ||
| list). ``definition`` stays NULL — that NULL is what makes it a list. | ||
| No ON CONFLICT: a second list with the same name is the unique index's | ||
| refusal to surface, never a merge.""" | ||
| query = f""" | ||
| INSERT INTO {SEGMENT_TABLE} | ||
| (merchant_id, name, member_count, computed_at, created_by) | ||
| VALUES ($1, $2, 0, now(), $3) | ||
| RETURNING {_SEGMENT_COLUMNS} | ||
| """ | ||
| return query, [merchant_id, name, created_by] | ||
|
|
||
|
|
||
| def get_segment_query(merchant_id: str, segment_id: str) -> Tuple[str, List[Any]]: | ||
| query = f""" | ||
| SELECT {_SEGMENT_COLUMNS} | ||
| FROM {SEGMENT_TABLE} | ||
| WHERE merchant_id = $1 AND id = $2 | ||
| """ | ||
| return query, [merchant_id, segment_id] | ||
|
|
||
|
|
||
| def list_segments_query( | ||
| merchant_id: str, limit: int, offset: int | ||
| ) -> Tuple[str, List[Any]]: | ||
| """Newest first — the order crm_segment_merchant_created_ix carries.""" | ||
| query = f""" | ||
| SELECT {_SEGMENT_COLUMNS} | ||
| FROM {SEGMENT_TABLE} | ||
| WHERE merchant_id = $1 | ||
| ORDER BY created_at DESC, id DESC | ||
| LIMIT $2 OFFSET $3 | ||
| """ | ||
| return query, [merchant_id, limit, offset] | ||
|
|
||
|
|
||
| def count_segments_query(merchant_id: str) -> Tuple[str, List[Any]]: | ||
| """The merchant's segment count, for the list's pager (in the body — | ||
| the RunPage ruling).""" | ||
| query = f""" | ||
| SELECT count(*)::int AS total | ||
| FROM {SEGMENT_TABLE} | ||
| WHERE merchant_id = $1 | ||
| """ | ||
| return query, [merchant_id] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| """Segments (canon T15, canon/05-audiences.md) — phase 1: a static list's | ||
| row, created and read. | ||
|
|
||
| A segment is a stored question; membership is computed at read time and | ||
| never stored. A static list is the one exception — an imported set IS its | ||
| members (phase 2 adds them; phase 4 compiles dynamic definitions). Until | ||
| then every segment this file creates is a static list. | ||
|
|
||
| What this file keeps: | ||
| - **A segment is never a permission list** (modules/05-outreach.md:233). | ||
| Nothing here sends, consents or suppresses; a broadcast still asks the | ||
| gate about every member. | ||
| - **The name is unique per merchant** (T15 col 3). The unique index referees | ||
| two racing creates; this file only names the refusal. | ||
| - **Counts carry their stamp** (T15 col 8): a new list's count is exact — | ||
| zero members, stamped at birth. | ||
| """ | ||
|
|
||
| from typing import Optional | ||
|
|
||
| from app.crm.outreach.db import UniqueViolation | ||
| from app.crm.outreach.db.accessors import segment as segment_accessor | ||
| from app.crm.outreach.schemas import Segment, SegmentPage | ||
|
|
||
|
|
||
| class SegmentNameTaken(Exception): | ||
| """The merchant already has a segment with this name (T15 col 3).""" | ||
|
|
||
|
|
||
| def segment_name(raw: str) -> str: | ||
| """The name as stored: surrounding whitespace dropped, so "Diwali VIPs" | ||
| and "Diwali VIPs " are one name to the unique index, not two lists a | ||
| merchant cannot tell apart. Pure. Raises ValueError when nothing is | ||
| left — a blank name is not a name. The 085 CHECK refuses a blank or | ||
| untrimmed name too, so a writer that skips this cannot split a name.""" | ||
| name = (raw or "").strip() | ||
| if not name: | ||
| raise ValueError("a segment needs a name") | ||
| return name | ||
|
|
||
|
|
||
| async def create_static_list( | ||
| merchant_id: str, name: str, created_by: Optional[str] | ||
| ) -> Segment: | ||
| """A new, empty static list. Raises SegmentNameTaken when the merchant | ||
| already has a segment by this name, ValueError on a blank name.""" | ||
| stored = segment_name(name) | ||
| try: | ||
| return await segment_accessor.insert_static_list( | ||
| merchant_id, stored, created_by | ||
| ) | ||
| except UniqueViolation as e: | ||
| raise SegmentNameTaken(stored) from e | ||
|
|
||
|
|
||
| async def get_segment(merchant_id: str, segment_id: str) -> Optional[Segment]: | ||
| """One segment, or None when this merchant has no such segment — another | ||
| merchant's id answers None too, never their row.""" | ||
| return await segment_accessor.get_segment(merchant_id, segment_id) | ||
|
|
||
|
|
||
| async def list_segments(merchant_id: str, limit: int, offset: int) -> SegmentPage: | ||
| """One page of the merchant's segments, newest first, and the total.""" | ||
| return SegmentPage( | ||
| items=await segment_accessor.list_segments(merchant_id, limit, offset), | ||
| total=await segment_accessor.count_segments(merchant_id), | ||
| ) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 MINOR: The page and its total come from two separate statements here, but this repo's own precedent for paged lists is one statement:
app/crm/outreach/db/queries/enrollment.py's list query carriescount(*) OVER ()on every row precisely so "a page and its '1–10 of 46' come from one statement", with a fallback count read only when the page comes back empty. Two statements cost an extra round trip on every page turn and can disagree under a concurrent write — the page renders rows from one snapshot whiletotalreflects another, so a merchant can see 3 items beside "total: 4".Suggestion — carry the total on the page query and keep the fallback for the empty page, mirroring the enrollment accessor:
and
segments.list_segmentspasses the pair straight intoSegmentPage.