Skip to content

feat(data-platform): implement PRP-2 schema and migrations#11

Closed
w7-mgfcode wants to merge 2 commits into
mainfrom
feat/prp-2-data-platform-schema
Closed

feat(data-platform): implement PRP-2 schema and migrations#11
w7-mgfcode wants to merge 2 commits into
mainfrom
feat/prp-2-data-platform-schema

Conversation

@w7-mgfcode
Copy link
Copy Markdown
Owner

@w7-mgfcode w7-mgfcode commented Jan 26, 2026

Summary

Implements PRP-2: Data Platform Schema + Migrations for the retail demand forecasting mini-warehouse.

  • Add 7 SQLAlchemy 2.0 ORM models (3 dimension + 4 fact tables)
  • Create Alembic baseline migration with proper downgrade
  • Add Pydantic v2 schemas for API validation
  • Add 32 unit tests + 11 integration tests
  • Add example documentation (schema README + SQL query examples)

Tables Created

Dimensions: store, product, calendar
Facts: sales_daily, price_history, promotion, inventory_snapshot_daily

Key Features

  • Grain protection via UNIQUE(date, store_id, product_id) on sales_daily
  • Check constraints for data quality (positive quantities, valid dates)
  • Composite indexes for time-range + store/product queries
  • Type-safe SQLAlchemy 2.0 patterns (Mapped[], mapped_column())

Test plan

  • uv run ruff check app/features/data_platform/ passes
  • uv run mypy app/features/data_platform/*.py passes (0 errors)
  • uv run pyright app/features/data_platform/ passes (0 errors)
  • uv run pytest app/features/data_platform/tests/test_models.py -v (32 passed)
  • uv run pytest -m integration (11 passed)
  • uv run alembic upgrade head succeeds
  • uv run alembic downgrade -1 && uv run alembic upgrade head (reversible)

🤖 Generated with Claude Code

Summary by Sourcery

Introduce a new data platform vertical slice with a retail forecasting mini-warehouse schema, migrations, validation schemas, tests, and example queries.

New Features:

  • Add data_platform feature module defining Store, Product, Calendar dimensions and SalesDaily, PriceHistory, Promotion, InventorySnapshotDaily fact tables using SQLAlchemy ORM.
  • Create Pydantic schemas for all data platform entities to support API-level validation and serialization.
  • Provide example SQL queries and schema documentation for KPI reporting and exogenous feature joins in the forecasting warehouse.

Enhancements:

  • Register data platform models with Alembic to enable autogeneration and management of migrations for the new schema.
  • Add a reusable async db_session fixture for integration tests that provisions and tears down database tables.

Documentation:

  • Document the data platform schema, table grains, constraints, and indexing strategy, and add example analytical and feature-engineering SQL queries.

Tests:

  • Add unit tests validating table structure, constraints, and relationships for all data platform ORM models.
  • Add integration tests that exercise unique, foreign key, and check constraints against a real PostgreSQL database.

Summary by CodeRabbit

Release Notes

  • New Features

    • Introduced a comprehensive data platform for retail forecasting with complete star-schema design, including store, product, and calendar dimensions with supporting sales, pricing, promotion, and inventory fact tables.
    • Added API validation schemas for all data entities.
  • Documentation

    • Added schema documentation detailing data model definitions, grain protection, and indexing strategies.
    • Included example SQL queries for KPI analysis, sales trends, and exogenous feature engineering for forecasting.

✏️ Tip: You can customize this high-level summary in your review settings.

w7-learn and others added 2 commits January 26, 2026 09:49
Create comprehensive Product Requirements Prompt for INITIAL-2 (Data Platform)
including:
- Mini-warehouse schema with dimension tables (store, product, calendar)
- Fact tables (sales_daily, price_history, promotion, inventory_snapshot_daily)
- SQLAlchemy 2.0 ORM model patterns with type annotations
- Alembic migration guidance and constraint naming conventions
- Grain protection via unique constraints for idempotent upserts
- Composite indexes for time-series query optimization
- Unit and integration test specifications
- Example SQL queries for KPIs and exogenous feature joins

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add mini-warehouse schema for retail demand forecasting:

Dimension tables:
- store: locations with code, region, city, store_type
- product: catalog with sku, category, brand, base_price
- calendar: time dimension with day_of_week, is_holiday

Fact tables:
- sales_daily: grain-protected (date, store_id, product_id)
- price_history: validity windows with valid_from/valid_to
- promotion: discount mechanics with pct and amount
- inventory_snapshot_daily: stockout detection

Key features:
- SQLAlchemy 2.0 patterns (Mapped[], mapped_column())
- Grain protection via UniqueConstraint for idempotent upserts
- Check constraints for data quality (positive qty, valid dates)
- Composite indexes for time-range + store/product queries
- Pydantic v2 schemas for API validation
- 32 unit tests + 11 integration tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented Jan 26, 2026

Reviewer's Guide

Implements the PRP-2 retail data platform mini-warehouse: adds SQLAlchemy 2.0 ORM models for 3 dimension and 4 fact tables, a baseline Alembic migration, Pydantic v2 schemas, integration/unit tests for constraints and relationships, shared async DB fixtures, and example schema/query documentation.

Entity relationship diagram for PRP2 data platform schema

erDiagram
  Store {
    int id PK
    string code UK
    string name
    string region
    string city
    string store_type
  }

  Product {
    int id PK
    string sku UK
    string name
    string category
    string brand
    decimal base_price
    decimal base_cost
  }

  Calendar {
    date date PK
    int day_of_week
    int month
    int quarter
    int year
    bool is_holiday
    string holiday_name
  }

  SalesDaily {
    int id PK
    date date FK
    int store_id FK
    int product_id FK
    int quantity
    decimal unit_price
    decimal total_amount
    UNIQUE date_store_product
  }

  PriceHistory {
    int id PK
    int product_id FK
    int store_id FK
    decimal price
    date valid_from
    date valid_to
  }

  Promotion {
    int id PK
    int product_id FK
    int store_id FK
    string name
    decimal discount_pct
    decimal discount_amount
    date start_date
    date end_date
  }

  InventorySnapshotDaily {
    int id PK
    date date FK
    int store_id FK
    int product_id FK
    int on_hand_qty
    int on_order_qty
    bool is_stockout
    UNIQUE date_store_product
  }

  Store ||--o{ SalesDaily : has
  Store ||--o{ PriceHistory : has
  Store ||--o{ Promotion : has
  Store ||--o{ InventorySnapshotDaily : has

  Product ||--o{ SalesDaily : has
  Product ||--o{ PriceHistory : has
  Product ||--o{ Promotion : has
  Product ||--o{ InventorySnapshotDaily : has

  Calendar ||--o{ SalesDaily : has
  Calendar ||--o{ InventorySnapshotDaily : has
Loading

Class diagram for new ORM models and Pydantic schemas

classDiagram
  class Base
  class TimestampMixin {
    datetime created_at
    datetime updated_at
  }

  class Store {
    +int id
    +str code
    +str name
    +str region
    +str city
    +str store_type
    +list~SalesDaily~ sales
    +list~PriceHistory~ price_history
    +list~Promotion~ promotions
    +list~InventorySnapshotDaily~ inventory_snapshots
  }

  class Product {
    +int id
    +str sku
    +str name
    +str category
    +str brand
    +Decimal base_price
    +Decimal base_cost
    +list~SalesDaily~ sales
    +list~PriceHistory~ price_history
    +list~Promotion~ promotions
    +list~InventorySnapshotDaily~ inventory_snapshots
  }

  class Calendar {
    +date date
    +int day_of_week
    +int month
    +int quarter
    +int year
    +bool is_holiday
    +str holiday_name
    +list~SalesDaily~ sales
    +list~InventorySnapshotDaily~ inventory_snapshots
  }

  class SalesDaily {
    +int id
    +date date
    +int store_id
    +int product_id
    +int quantity
    +Decimal unit_price
    +Decimal total_amount
    +Store store
    +Product product
    +Calendar calendar
  }

  class PriceHistory {
    +int id
    +int product_id
    +int store_id
    +Decimal price
    +date valid_from
    +date valid_to
    +Product product
    +Store store
  }

  class Promotion {
    +int id
    +int product_id
    +int store_id
    +str name
    +Decimal discount_pct
    +Decimal discount_amount
    +date start_date
    +date end_date
    +Product product
    +Store store
  }

  class InventorySnapshotDaily {
    +int id
    +date date
    +int store_id
    +int product_id
    +int on_hand_qty
    +int on_order_qty
    +bool is_stockout
    +Calendar calendar
    +Store store
    +Product product
  }

  TimestampMixin <|-- Store
  Base <|-- Store
  TimestampMixin <|-- Product
  Base <|-- Product
  TimestampMixin <|-- Calendar
  Base <|-- Calendar
  TimestampMixin <|-- SalesDaily
  Base <|-- SalesDaily
  TimestampMixin <|-- PriceHistory
  Base <|-- PriceHistory
  TimestampMixin <|-- Promotion
  Base <|-- Promotion
  TimestampMixin <|-- InventorySnapshotDaily
  Base <|-- InventorySnapshotDaily

  Store "1" o-- "*" SalesDaily : sales
  Product "1" o-- "*" SalesDaily : sales
  Calendar "1" o-- "*" SalesDaily : sales

  Store "1" o-- "*" PriceHistory : price_history
  Product "1" o-- "*" PriceHistory : price_history

  Store "1" o-- "*" Promotion : promotions
  Product "1" o-- "*" Promotion : promotions

  Store "1" o-- "*" InventorySnapshotDaily : inventory_snapshots
  Product "1" o-- "*" InventorySnapshotDaily : inventory_snapshots
  Calendar "1" o-- "*" InventorySnapshotDaily : inventory_snapshots

  class StoreBase {
    +str code
    +str name
    +str region
    +str city
    +str store_type
  }

  class StoreCreate
  class StoreRead {
    +int id
  }

  class ProductBase {
    +str sku
    +str name
    +str category
    +str brand
    +Decimal base_price
    +Decimal base_cost
  }

  class ProductCreate
  class ProductRead {
    +int id
  }

  class CalendarBase {
    +date date
    +int day_of_week
    +int month
    +int quarter
    +int year
    +bool is_holiday
    +str holiday_name
  }

  class CalendarCreate
  class CalendarRead

  class SalesDailyBase {
    +date date
    +int store_id
    +int product_id
    +int quantity
    +Decimal unit_price
    +Decimal total_amount
  }

  class SalesDailyCreate
  class SalesDailyRead {
    +int id
  }

  class PriceHistoryBase {
    +int product_id
    +int store_id
    +Decimal price
    +date valid_from
    +date valid_to
  }

  class PriceHistoryCreate
  class PriceHistoryRead {
    +int id
  }

  class PromotionBase {
    +int product_id
    +int store_id
    +str name
    +Decimal discount_pct
    +Decimal discount_amount
    +date start_date
    +date end_date
  }

  class PromotionCreate
  class PromotionRead {
    +int id
  }

  class InventorySnapshotDailyBase {
    +date date
    +int store_id
    +int product_id
    +int on_hand_qty
    +int on_order_qty
    +bool is_stockout
  }

  class InventorySnapshotDailyCreate
  class InventorySnapshotDailyRead {
    +int id
  }

  StoreBase <|-- StoreCreate
  StoreBase <|-- StoreRead
  ProductBase <|-- ProductCreate
  ProductBase <|-- ProductRead
  CalendarBase <|-- CalendarCreate
  CalendarBase <|-- CalendarRead
  SalesDailyBase <|-- SalesDailyCreate
  SalesDailyBase <|-- SalesDailyRead
  PriceHistoryBase <|-- PriceHistoryCreate
  PriceHistoryBase <|-- PriceHistoryRead
  PromotionBase <|-- PromotionCreate
  PromotionBase <|-- PromotionRead
  InventorySnapshotDailyBase <|-- InventorySnapshotDailyCreate
  InventorySnapshotDailyBase <|-- InventorySnapshotDailyRead

  StoreRead ..> Store : from_attributes
  ProductRead ..> Product : from_attributes
  CalendarRead ..> Calendar : from_attributes
  SalesDailyRead ..> SalesDaily : from_attributes
  PriceHistoryRead ..> PriceHistory : from_attributes
  PromotionRead ..> Promotion : from_attributes
  InventorySnapshotDailyRead ..> InventorySnapshotDaily : from_attributes
Loading

File-Level Changes

Change Details Files
Introduces SQLAlchemy 2.0 ORM models for data platform dimensions and facts with relationships, constraints, and indexes aligned to forecasting use-cases.
  • Adds Store, Product, and Calendar dimension models using TimestampMixin and SQLAlchemy 2.0 typed ORM patterns
  • Adds SalesDaily, PriceHistory, Promotion, and InventorySnapshotDaily fact models with explicit foreign keys to dimensions
  • Enforces grain protection via unique constraints on daily facts and adds composite indexes for common date+dimension query patterns
  • Adds data-quality CheckConstraints (e.g., positive quantities/prices, valid date ranges) and nullable FKs for chain-wide attributes
app/features/data_platform/models.py
Adds Pydantic v2 schemas for API-level validation of data platform entities.
  • Defines create/read schemas for Store, Product, Calendar, SalesDaily, PriceHistory, Promotion, and InventorySnapshotDaily
  • Uses field constraints (lengths, ranges, non-negative money/quantities) and from_attributes config for ORM compatibility
app/features/data_platform/schemas.py
Sets up the data_platform feature package and exports models for reuse and Alembic discovery.
  • Creates feature package skeleton and re-exports ORM models from the package init
  • Provides a tests package marker for feature-specific tests
app/features/data_platform/__init__.py
app/features/data_platform/tests/__init__.py
Creates a baseline Alembic migration that materializes the data platform schema with reversible upgrade/downgrade.
  • Creates all 7 tables with PKs, FKs, unique constraints, check constraints, and timestamp columns
  • Defines named composite indexes consistent with the model definitions and query patterns
  • Implements downgrade sequence dropping indexes and tables in dependency-safe reverse order
alembic/versions/e1165ebcef61_create_data_platform_tables.py
Wires the new models into Alembic autogeneration by importing the feature module in the migration environment.
  • Imports the data_platform models module in env.py purely for side effects so Alembic sees the metadata
alembic/env.py
Adds unit tests to validate ORM model metadata (tables, columns, relationships, and constraints).
  • Verifies tablename values, required columns, uniqueness, and numeric types
  • Asserts presence and names of critical constraints and relationships across dimension and fact models
app/features/data_platform/tests/test_models.py
Adds integration tests and fixtures to exercise database-level constraint enforcement against a real PostgreSQL instance.
  • Provides async db_session and sample dimension fixtures that create/drop all tables around tests
  • Validates unique, foreign-key, and check constraints for SalesDaily, InventorySnapshotDaily, Store, Product, and Calendar in async tests
tests/conftest.py
app/features/data_platform/tests/conftest.py
app/features/data_platform/tests/test_constraints.py
Adds example documentation and query patterns illustrating how to use the new schema for KPIs and exogenous feature joins.
  • Documents table grains, keys, constraints, and relationship structure in a schema README
  • Provides KPI-focused SQL queries for sales, trends, YoY growth, and mix analysis
  • Provides SQL examples for joining sales to price, promotion, inventory, and building time-safe lag/rolling features
examples/schema/README.md
examples/queries/kpi_sales.sql
examples/queries/exog_join.sql
Adds the PRP-2 design document capturing rationale, requirements, and implementation blueprint for the data platform schema and migrations.
  • Describes goals, success criteria, ERD, implementation tasks, and validation steps for the schema
  • Captures known SQLAlchemy/Alembic gotchas and project conventions used in this implementation
PRPs/PRP-2-data-platform-schema.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jan 26, 2026

Caution

Review failed

The pull request is closed.

📝 Walkthrough

Walkthrough

Introduces a comprehensive Data Platform schema with SQLAlchemy ORM models implementing a star schema (3 dimensions: Store, Product, Calendar; 4 fact tables), Alembic migrations, Pydantic validation schemas, comprehensive tests validating constraints and models, and SQL query examples for analytics.

Changes

Cohort / File(s) Summary
Design & Planning
PRPs/PRP-2-data-platform-schema.md
Comprehensive specification document defining the complete data platform schema design, star-schema layout, implementation blueprint with SQLAlchemy 2.0 patterns, Alembic integration strategy, validation steps (ruff, mypy, pytest), and detailed task list for implementation and quality gates.
ORM Models & API Schemas
app/features/data_platform/models.py, app/features/data_platform/schemas.py, app/features/data_platform/__init__.py
Introduces 7 ORM models (Store, Product, Calendar, SalesDaily, PriceHistory, Promotion, InventorySnapshotDaily) with bidirectional relationships, constraints (unique grain, foreign keys, check constraints), and indexes. Defines corresponding Pydantic Create/Read schemas with field validation and from_attributes configuration for ORM mapping.
Database Migrations & Configuration
alembic/env.py, alembic/versions/e1165ebcef61_create_data_platform_tables.py
Updates Alembic environment to import data_platform models for autogenerate detection. Adds migration script creating all 7 tables with grain-protection unique constraints (date, store_id, product_id), check constraints on quantities/prices, multiple indexes on foreign keys and composite query patterns, and corresponding downgrade steps.
Model & Constraint Tests
app/features/data_platform/tests/test_models.py, app/features/data_platform/tests/test_constraints.py, app/features/data_platform/tests/conftest.py
Comprehensive test suite with 263 lines of unit tests validating table names, columns, data types, constraints, and relationships. Integration tests (286 lines) verify unique constraint enforcement, foreign key integrity, and check constraint validation via IntegrityError. Fixtures provide async db_session, sample_store, sample_product, and sample_calendar for isolated testing.
Test Infrastructure
tests/conftest.py
Adds async SQLAlchemy db_session fixture with automatic table creation, session lifecycle management (rollback on exit), and table cleanup, enabling integration test database isolation.
SQL Examples & Documentation
examples/queries/exog_join.sql, examples/queries/kpi_sales.sql, examples/schema/README.md, app/features/data_platform/tests/__init__.py
Provides extensive SQL examples demonstrating exogenous feature joins (point-in-time joins with price_history, promotions, inventory), forecasting feature engineering (lag/rolling averages), and KPI analytics (daily summaries, trends, YoY growth, category mix). Schema documentation outlines grain definitions, indexes, constraints, and governance patterns.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 A warehouse of wonders, now perfectly schemed,
With dimensions and facts, just as we dreamed,
Store, product, calendar—a star shines so bright,
Constraints guard the grain, keeping data just right!
From models to migrations, the platform takes flight.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 3 issues, and left some high level feedback:

  • There are two separate db_session fixtures (in tests/conftest.py and app/features/data_platform/tests/conftest.py) with nearly identical logic; consider consolidating to a single shared fixture to avoid divergence in how test databases are initialized and cleaned up.
  • The db_session fixtures create tables with Base.metadata.create_all() while your production schema is managed via Alembic migrations; consider switching the test setup to run alembic upgrade head instead so tests exercise the same schema (constraints/indexes) as deployed environments.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- There are two separate `db_session` fixtures (in `tests/conftest.py` and `app/features/data_platform/tests/conftest.py`) with nearly identical logic; consider consolidating to a single shared fixture to avoid divergence in how test databases are initialized and cleaned up.
- The `db_session` fixtures create tables with `Base.metadata.create_all()` while your production schema is managed via Alembic migrations; consider switching the test setup to run `alembic upgrade head` instead so tests exercise the same schema (constraints/indexes) as deployed environments.

## Individual Comments

### Comment 1
<location> `app/features/data_platform/models.py:211-212` </location>
<code_context>
+    valid_to: Mapped[date | None] = mapped_column(Date)
+
+    # Relationships
+    product: Mapped["Product"] = relationship(back_populates="price_history")
+    store: Mapped["Store | None"] = relationship(back_populates="price_history")
+
+    __table_args__ = (
</code_context>

<issue_to_address>
**issue (bug_risk):** Forward-ref type for `store` uses the `| None` syntax inside the string, which will not resolve correctly.

In SQLAlchemy, string-based annotations should contain only the model name, without typing operators. `Mapped["Store | None"]` will be treated as the literal name `"Store | None"` and won’t resolve to `Store`. Use `Mapped[Store | None]` (with `from __future__ import annotations`) or `Mapped["Store"]` and rely on `nullable=True` on the FK to represent optionality.
</issue_to_address>

### Comment 2
<location> `app/features/data_platform/tests/test_constraints.py:23-32` </location>
<code_context>
+from app.features.data_platform.models import Calendar, Product, SalesDaily, Store
+
+
+@pytest.mark.integration
+class TestSalesDailyConstraints:
+    """Integration tests for SalesDaily constraints."""
</code_context>

<issue_to_address>
**suggestion (testing):** Add integration tests for `PriceHistory` and `Promotion` database constraints.

The current integration tests cover constraints on `SalesDaily`, `InventorySnapshotDaily`, `Store`, `Product`, and `Calendar`, but none for `PriceHistory` or `Promotion`. To validate those schemas as well, please add tests that:

* `PriceHistory`
  * Assert `price < 0` raises `IntegrityError` (`ck_price_history_price_positive`).
  * Assert `valid_to < valid_from` raises `IntegrityError` (`ck_price_history_valid_dates`).
  * Assert a valid row inserts successfully.
* `Promotion`
  * Assert `end_date < start_date` raises `IntegrityError` (`ck_promotion_valid_dates`).
  * Assert `discount_pct < 0` and `discount_pct > 1` raise `IntegrityError` (`ck_promotion_discount_pct_range`).
  * Assert `discount_amount < 0` raises `IntegrityError` (`ck_promotion_discount_amount_positive`).
  * Assert a valid row inserts successfully.

You can follow the pattern of `TestSalesDailyConstraints` and reuse `sample_store`, `sample_product`, and `sample_calendar` fixtures where needed.

Suggested implementation:

```python
from app.features.data_platform.models import (
    Calendar,
    InventorySnapshotDaily,
    PriceHistory,
    Product,
    Promotion,
    SalesDaily,
    Store,
)

```

```python
@pytest.mark.integration
class TestPriceHistoryConstraints:
    """Integration tests for PriceHistory constraints."""

    async def test_price_must_be_non_negative(
        self,
        db_session: AsyncSession,
        sample_store: Store,
        sample_product: Product,
        sample_calendar: Calendar,
    ):
        """ck_price_history_price_positive: price < 0 should raise IntegrityError."""
        price_history = PriceHistory(
            store_id=sample_store.id,
            product_id=sample_product.id,
            calendar_id=sample_calendar.id,
            price=-1,
            valid_from=sample_calendar.date,
            valid_to=sample_calendar.date,
        )

        db_session.add(price_history)
        with pytest.raises(IntegrityError):
            await db_session.flush()

    async def test_valid_to_must_be_on_or_after_valid_from(
        self,
        db_session: AsyncSession,
        sample_store: Store,
        sample_product: Product,
        sample_calendar: Calendar,
    ):
        """ck_price_history_valid_dates: valid_to < valid_from should raise IntegrityError."""
        # Assuming sample_calendar provides a valid date; create an earlier date manually
        earlier_date = sample_calendar.date - datetime.timedelta(days=1)

        price_history = PriceHistory(
            store_id=sample_store.id,
            product_id=sample_product.id,
            calendar_id=sample_calendar.id,
            price=10,
            valid_from=sample_calendar.date,
            valid_to=earlier_date,
        )

        db_session.add(price_history)
        with pytest.raises(IntegrityError):
            await db_session.flush()

    async def test_valid_price_history_inserts_successfully(
        self,
        db_session: AsyncSession,
        sample_store: Store,
        sample_product: Product,
        sample_calendar: Calendar,
    ):
        """A valid PriceHistory row should insert successfully."""
        price_history = PriceHistory(
            store_id=sample_store.id,
            product_id=sample_product.id,
            calendar_id=sample_calendar.id,
            price=10,
            valid_from=sample_calendar.date,
            valid_to=sample_calendar.date,
        )

        db_session.add(price_history)
        await db_session.flush()


@pytest.mark.integration
class TestPromotionConstraints:
    """Integration tests for Promotion constraints."""

    async def test_end_date_must_be_on_or_after_start_date(
        self,
        db_session: AsyncSession,
        sample_store: Store,
        sample_product: Product,
        sample_calendar: Calendar,
    ):
        """ck_promotion_valid_dates: end_date < start_date should raise IntegrityError."""
        later_date = sample_calendar.date + datetime.timedelta(days=1)

        promotion = Promotion(
            store_id=sample_store.id,
            product_id=sample_product.id,
            start_date=later_date,
            end_date=sample_calendar.date,
            discount_pct=0.1,
            discount_amount=0,
        )

        db_session.add(promotion)
        with pytest.raises(IntegrityError):
            await db_session.flush()

    async def test_discount_pct_must_be_between_0_and_1(
        self,
        db_session: AsyncSession,
        sample_store: Store,
        sample_product: Product,
        sample_calendar: Calendar,
    ):
        """ck_promotion_discount_pct_range: discount_pct outside [0, 1] should raise IntegrityError."""
        base_kwargs = dict(
            store_id=sample_store.id,
            product_id=sample_product.id,
            start_date=sample_calendar.date,
            end_date=sample_calendar.date,
            discount_amount=0,
        )

        promotion_negative_pct = Promotion(discount_pct=-0.1, **base_kwargs)
        db_session.add(promotion_negative_pct)
        with pytest.raises(IntegrityError):
            await db_session.flush()
        await db_session.rollback()

        promotion_over_one_pct = Promotion(discount_pct=1.1, **base_kwargs)
        db_session.add(promotion_over_one_pct)
        with pytest.raises(IntegrityError):
            await db_session.flush()

    async def test_discount_amount_must_be_non_negative(
        self,
        db_session: AsyncSession,
        sample_store: Store,
        sample_product: Product,
        sample_calendar: Calendar,
    ):
        """ck_promotion_discount_amount_positive: discount_amount < 0 should raise IntegrityError."""
        promotion = Promotion(
            store_id=sample_store.id,
            product_id=sample_product.id,
            start_date=sample_calendar.date,
            end_date=sample_calendar.date,
            discount_pct=0,
            discount_amount=-1,
        )

        db_session.add(promotion)
        with pytest.raises(IntegrityError):
            await db_session.flush()

    async def test_valid_promotion_inserts_successfully(
        self,
        db_session: AsyncSession,
        sample_store: Store,
        sample_product: Product,
        sample_calendar: Calendar,
    ):
        """A valid Promotion row should insert successfully."""
        promotion = Promotion(
            store_id=sample_store.id,
            product_id=sample_product.id,
            start_date=sample_calendar.date,
            end_date=sample_calendar.date,
            discount_pct=0.2,
            discount_amount=0,
        )

        db_session.add(promotion)
        await db_session.flush()


@pytest.mark.integration
class TestSalesDailyConstraints:
    """Integration tests for SalesDaily constraints."""

```

1. Ensure `datetime` and `IntegrityError` are imported at the top of the file if they are not already:

```python
import datetime
from sqlalchemy.exc import IntegrityError
```

2. If `PriceHistory` or `Promotion` use different field names or relationships (e.g., `store`, `product`, `calendar` relationships instead of `*_id` columns), adjust the constructor arguments to match the actual model definitions.
3. If the `sample_calendar` fixture exposes fields differently (e.g., `sample_calendar.date_id` vs `sample_calendar.date`), update `valid_from`, `valid_to`, `start_date`, and `end_date` accordingly.
</issue_to_address>

### Comment 3
<location> `app/features/data_platform/tests/__init__.py:1` </location>
<code_context>
+"""Tests for data platform feature."""
</code_context>

<issue_to_address>
**suggestion (testing):** Add unit tests for the Pydantic schemas in `schemas.py` to validate API-facing constraints.

I don’t see any tests exercising the Pydantic schemas in `app/features/data_platform/schemas.py`. Since they’re the API-facing validation layer, please add unit tests that cover:

* Field length/required-ness (e.g. `StoreBase.code` max_length, `name` non-empty).
* Numeric bounds (e.g. `base_price`/`base_cost` `ge=0`, `discount_pct` in `[0, 1]`, `on_hand_qty`/`on_order_qty` non-negative).
* Optional vs required IDs (e.g. `store_id` nullable vs positive when set).
* Any date consistency you choose to enforce at the schema level.

A small parametrized `test_schemas.py` that instantiates each `*Create` schema with valid data and a few invalid variants (asserting `ValidationError`) should be sufficient.

Suggested implementation:

```python
"""Tests for data platform feature schemas."""

from datetime import date, timedelta

import pytest
from pydantic import ValidationError

from app.features.data_platform import schemas


# --- Store schemas ---------------------------------------------------------


def test_store_create_valid():
    """StoreCreate accepts valid minimal payload."""
    store = schemas.StoreCreate(
        code="STORE_001",
        name="Main store",
    )
    assert store.code == "STORE_001"
    assert store.name == "Main store"


@pytest.mark.parametrize(
    "field, value",
    [
        ("code", ""),  # empty code should be rejected by max_length/min_length or custom validators
        ("code", "x" * 256),  # past a typical max_length=255
        ("name", ""),  # name should be non-empty
    ],
)
def test_store_create_string_constraints(field, value):
    """StoreCreate enforces code max length and name non-emptiness."""
    base_payload = {
        "code": "STORE_001",
        "name": "Main store",
    }
    base_payload[field] = value

    with pytest.raises(ValidationError):
        schemas.StoreCreate(**base_payload)


# --- Product / pricing schemas --------------------------------------------


def _valid_product_payload():
    """Helper: minimally valid product payload for ProductCreate-like schema."""
    return {
        "sku": "SKU-123",
        "name": "Test product",
        "base_price": 10.0,
        "base_cost": 5.0,
        "discount_pct": 0.25,
    }


def test_product_create_valid():
    """ProductCreate accepts valid numeric ranges."""
    product = schemas.ProductCreate(**_valid_product_payload())
    assert product.base_price == 10.0
    assert product.base_cost == 5.0
    assert 0 <= product.discount_pct <= 1


@pytest.mark.parametrize(
    "field, value",
    [
        ("base_price", -0.01),
        ("base_cost", -1),
        ("discount_pct", -0.01),
        ("discount_pct", 1.01),
    ],
)
def test_product_create_numeric_bounds(field, value):
    """ProductCreate enforces non-negative prices and discount_pct in [0, 1]."""
    payload = _valid_product_payload()
    payload[field] = value

    with pytest.raises(ValidationError):
        schemas.ProductCreate(**payload)


# --- Inventory / stock schemas --------------------------------------------


def _valid_inventory_payload():
    """Helper: minimally valid inventory payload for InventoryCreate-like schema."""
    today = date.today()
    return {
        "sku": "SKU-123",
        "store_id": None,
        "on_hand_qty": 0,
        "on_order_qty": 0,
        "effective_date": today,
        "end_date": today + timedelta(days=1),
    }


def test_inventory_create_valid_with_null_store_id():
    """InventoryCreate allows nullable store_id."""
    inv = schemas.InventoryCreate(**_valid_inventory_payload())
    assert inv.store_id is None
    assert inv.on_hand_qty == 0
    assert inv.on_order_qty == 0
    assert inv.effective_date <= inv.end_date


def test_inventory_create_valid_with_positive_store_id():
    """InventoryCreate accepts positive store_id when provided."""
    payload = _valid_inventory_payload()
    payload["store_id"] = 123
    inv = schemas.InventoryCreate(**payload)
    assert inv.store_id == 123


@pytest.mark.parametrize("field", ["on_hand_qty", "on_order_qty"])
def test_inventory_create_non_negative_quantities(field):
    """InventoryCreate enforces non-negative inventory quantities."""
    payload = _valid_inventory_payload()
    payload[field] = -1

    with pytest.raises(ValidationError):
        schemas.InventoryCreate(**payload)


def test_inventory_create_store_id_positive_when_set():
    """InventoryCreate requires store_id, when set, to be positive."""
    payload = _valid_inventory_payload()
    payload["store_id"] = 0

    with pytest.raises(ValidationError):
        schemas.InventoryCreate(**payload)


def test_inventory_create_date_consistency():
    """InventoryCreate enforces effective_date <= end_date."""
    today = date.today()
    payload = _valid_inventory_payload()
    payload["effective_date"] = today
    payload["end_date"] = today - timedelta(days=1)

    with pytest.raises(ValidationError):
        schemas.InventoryCreate(**payload)

```

These tests assume the following schema names and fields in `app/features/data_platform/schemas.py`:

- `StoreCreate` with fields: `code` (str with max_length/min_length), `name` (non-empty str).
- `ProductCreate` with fields: `sku` (str), `name` (str), `base_price` (float, `ge=0`), `base_cost` (float, `ge=0`), `discount_pct` (float between 0 and 1 inclusive).
- `InventoryCreate` with fields: `sku` (str), `store_id` (Optional[int], must be positive when not None), `on_hand_qty` and `on_order_qty` (non-negative numeric), `effective_date` and `end_date` (dates with a validator enforcing `effective_date <= end_date`).

If your actual schema names or fields differ, adjust the imports and field names in these tests accordingly, or add the corresponding `*Create` schemas and Pydantic field constraints so these tests pass.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +211 to +212
product: Mapped["Product"] = relationship(back_populates="price_history")
store: Mapped["Store | None"] = relationship(back_populates="price_history")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): Forward-ref type for store uses the | None syntax inside the string, which will not resolve correctly.

In SQLAlchemy, string-based annotations should contain only the model name, without typing operators. Mapped["Store | None"] will be treated as the literal name "Store | None" and won’t resolve to Store. Use Mapped[Store | None] (with from __future__ import annotations) or Mapped["Store"] and rely on nullable=True on the FK to represent optionality.

Comment on lines +23 to +32
@pytest.mark.integration
class TestSalesDailyConstraints:
"""Integration tests for SalesDaily constraints."""

async def test_unique_constraint_prevents_duplicates(
self,
db_session: AsyncSession,
sample_store: Store,
sample_product: Product,
sample_calendar: Calendar,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (testing): Add integration tests for PriceHistory and Promotion database constraints.

The current integration tests cover constraints on SalesDaily, InventorySnapshotDaily, Store, Product, and Calendar, but none for PriceHistory or Promotion. To validate those schemas as well, please add tests that:

  • PriceHistory
    • Assert price < 0 raises IntegrityError (ck_price_history_price_positive).
    • Assert valid_to < valid_from raises IntegrityError (ck_price_history_valid_dates).
    • Assert a valid row inserts successfully.
  • Promotion
    • Assert end_date < start_date raises IntegrityError (ck_promotion_valid_dates).
    • Assert discount_pct < 0 and discount_pct > 1 raise IntegrityError (ck_promotion_discount_pct_range).
    • Assert discount_amount < 0 raises IntegrityError (ck_promotion_discount_amount_positive).
    • Assert a valid row inserts successfully.

You can follow the pattern of TestSalesDailyConstraints and reuse sample_store, sample_product, and sample_calendar fixtures where needed.

Suggested implementation:

from app.features.data_platform.models import (
    Calendar,
    InventorySnapshotDaily,
    PriceHistory,
    Product,
    Promotion,
    SalesDaily,
    Store,
)
@pytest.mark.integration
class TestPriceHistoryConstraints:
    """Integration tests for PriceHistory constraints."""

    async def test_price_must_be_non_negative(
        self,
        db_session: AsyncSession,
        sample_store: Store,
        sample_product: Product,
        sample_calendar: Calendar,
    ):
        """ck_price_history_price_positive: price < 0 should raise IntegrityError."""
        price_history = PriceHistory(
            store_id=sample_store.id,
            product_id=sample_product.id,
            calendar_id=sample_calendar.id,
            price=-1,
            valid_from=sample_calendar.date,
            valid_to=sample_calendar.date,
        )

        db_session.add(price_history)
        with pytest.raises(IntegrityError):
            await db_session.flush()

    async def test_valid_to_must_be_on_or_after_valid_from(
        self,
        db_session: AsyncSession,
        sample_store: Store,
        sample_product: Product,
        sample_calendar: Calendar,
    ):
        """ck_price_history_valid_dates: valid_to < valid_from should raise IntegrityError."""
        # Assuming sample_calendar provides a valid date; create an earlier date manually
        earlier_date = sample_calendar.date - datetime.timedelta(days=1)

        price_history = PriceHistory(
            store_id=sample_store.id,
            product_id=sample_product.id,
            calendar_id=sample_calendar.id,
            price=10,
            valid_from=sample_calendar.date,
            valid_to=earlier_date,
        )

        db_session.add(price_history)
        with pytest.raises(IntegrityError):
            await db_session.flush()

    async def test_valid_price_history_inserts_successfully(
        self,
        db_session: AsyncSession,
        sample_store: Store,
        sample_product: Product,
        sample_calendar: Calendar,
    ):
        """A valid PriceHistory row should insert successfully."""
        price_history = PriceHistory(
            store_id=sample_store.id,
            product_id=sample_product.id,
            calendar_id=sample_calendar.id,
            price=10,
            valid_from=sample_calendar.date,
            valid_to=sample_calendar.date,
        )

        db_session.add(price_history)
        await db_session.flush()


@pytest.mark.integration
class TestPromotionConstraints:
    """Integration tests for Promotion constraints."""

    async def test_end_date_must_be_on_or_after_start_date(
        self,
        db_session: AsyncSession,
        sample_store: Store,
        sample_product: Product,
        sample_calendar: Calendar,
    ):
        """ck_promotion_valid_dates: end_date < start_date should raise IntegrityError."""
        later_date = sample_calendar.date + datetime.timedelta(days=1)

        promotion = Promotion(
            store_id=sample_store.id,
            product_id=sample_product.id,
            start_date=later_date,
            end_date=sample_calendar.date,
            discount_pct=0.1,
            discount_amount=0,
        )

        db_session.add(promotion)
        with pytest.raises(IntegrityError):
            await db_session.flush()

    async def test_discount_pct_must_be_between_0_and_1(
        self,
        db_session: AsyncSession,
        sample_store: Store,
        sample_product: Product,
        sample_calendar: Calendar,
    ):
        """ck_promotion_discount_pct_range: discount_pct outside [0, 1] should raise IntegrityError."""
        base_kwargs = dict(
            store_id=sample_store.id,
            product_id=sample_product.id,
            start_date=sample_calendar.date,
            end_date=sample_calendar.date,
            discount_amount=0,
        )

        promotion_negative_pct = Promotion(discount_pct=-0.1, **base_kwargs)
        db_session.add(promotion_negative_pct)
        with pytest.raises(IntegrityError):
            await db_session.flush()
        await db_session.rollback()

        promotion_over_one_pct = Promotion(discount_pct=1.1, **base_kwargs)
        db_session.add(promotion_over_one_pct)
        with pytest.raises(IntegrityError):
            await db_session.flush()

    async def test_discount_amount_must_be_non_negative(
        self,
        db_session: AsyncSession,
        sample_store: Store,
        sample_product: Product,
        sample_calendar: Calendar,
    ):
        """ck_promotion_discount_amount_positive: discount_amount < 0 should raise IntegrityError."""
        promotion = Promotion(
            store_id=sample_store.id,
            product_id=sample_product.id,
            start_date=sample_calendar.date,
            end_date=sample_calendar.date,
            discount_pct=0,
            discount_amount=-1,
        )

        db_session.add(promotion)
        with pytest.raises(IntegrityError):
            await db_session.flush()

    async def test_valid_promotion_inserts_successfully(
        self,
        db_session: AsyncSession,
        sample_store: Store,
        sample_product: Product,
        sample_calendar: Calendar,
    ):
        """A valid Promotion row should insert successfully."""
        promotion = Promotion(
            store_id=sample_store.id,
            product_id=sample_product.id,
            start_date=sample_calendar.date,
            end_date=sample_calendar.date,
            discount_pct=0.2,
            discount_amount=0,
        )

        db_session.add(promotion)
        await db_session.flush()


@pytest.mark.integration
class TestSalesDailyConstraints:
    """Integration tests for SalesDaily constraints."""
  1. Ensure datetime and IntegrityError are imported at the top of the file if they are not already:
import datetime
from sqlalchemy.exc import IntegrityError
  1. If PriceHistory or Promotion use different field names or relationships (e.g., store, product, calendar relationships instead of *_id columns), adjust the constructor arguments to match the actual model definitions.
  2. If the sample_calendar fixture exposes fields differently (e.g., sample_calendar.date_id vs sample_calendar.date), update valid_from, valid_to, start_date, and end_date accordingly.

@@ -0,0 +1 @@
"""Tests for data platform feature."""
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (testing): Add unit tests for the Pydantic schemas in schemas.py to validate API-facing constraints.

I don’t see any tests exercising the Pydantic schemas in app/features/data_platform/schemas.py. Since they’re the API-facing validation layer, please add unit tests that cover:

  • Field length/required-ness (e.g. StoreBase.code max_length, name non-empty).
  • Numeric bounds (e.g. base_price/base_cost ge=0, discount_pct in [0, 1], on_hand_qty/on_order_qty non-negative).
  • Optional vs required IDs (e.g. store_id nullable vs positive when set).
  • Any date consistency you choose to enforce at the schema level.

A small parametrized test_schemas.py that instantiates each *Create schema with valid data and a few invalid variants (asserting ValidationError) should be sufficient.

Suggested implementation:

"""Tests for data platform feature schemas."""

from datetime import date, timedelta

import pytest
from pydantic import ValidationError

from app.features.data_platform import schemas


# --- Store schemas ---------------------------------------------------------


def test_store_create_valid():
    """StoreCreate accepts valid minimal payload."""
    store = schemas.StoreCreate(
        code="STORE_001",
        name="Main store",
    )
    assert store.code == "STORE_001"
    assert store.name == "Main store"


@pytest.mark.parametrize(
    "field, value",
    [
        ("code", ""),  # empty code should be rejected by max_length/min_length or custom validators
        ("code", "x" * 256),  # past a typical max_length=255
        ("name", ""),  # name should be non-empty
    ],
)
def test_store_create_string_constraints(field, value):
    """StoreCreate enforces code max length and name non-emptiness."""
    base_payload = {
        "code": "STORE_001",
        "name": "Main store",
    }
    base_payload[field] = value

    with pytest.raises(ValidationError):
        schemas.StoreCreate(**base_payload)


# --- Product / pricing schemas --------------------------------------------


def _valid_product_payload():
    """Helper: minimally valid product payload for ProductCreate-like schema."""
    return {
        "sku": "SKU-123",
        "name": "Test product",
        "base_price": 10.0,
        "base_cost": 5.0,
        "discount_pct": 0.25,
    }


def test_product_create_valid():
    """ProductCreate accepts valid numeric ranges."""
    product = schemas.ProductCreate(**_valid_product_payload())
    assert product.base_price == 10.0
    assert product.base_cost == 5.0
    assert 0 <= product.discount_pct <= 1


@pytest.mark.parametrize(
    "field, value",
    [
        ("base_price", -0.01),
        ("base_cost", -1),
        ("discount_pct", -0.01),
        ("discount_pct", 1.01),
    ],
)
def test_product_create_numeric_bounds(field, value):
    """ProductCreate enforces non-negative prices and discount_pct in [0, 1]."""
    payload = _valid_product_payload()
    payload[field] = value

    with pytest.raises(ValidationError):
        schemas.ProductCreate(**payload)


# --- Inventory / stock schemas --------------------------------------------


def _valid_inventory_payload():
    """Helper: minimally valid inventory payload for InventoryCreate-like schema."""
    today = date.today()
    return {
        "sku": "SKU-123",
        "store_id": None,
        "on_hand_qty": 0,
        "on_order_qty": 0,
        "effective_date": today,
        "end_date": today + timedelta(days=1),
    }


def test_inventory_create_valid_with_null_store_id():
    """InventoryCreate allows nullable store_id."""
    inv = schemas.InventoryCreate(**_valid_inventory_payload())
    assert inv.store_id is None
    assert inv.on_hand_qty == 0
    assert inv.on_order_qty == 0
    assert inv.effective_date <= inv.end_date


def test_inventory_create_valid_with_positive_store_id():
    """InventoryCreate accepts positive store_id when provided."""
    payload = _valid_inventory_payload()
    payload["store_id"] = 123
    inv = schemas.InventoryCreate(**payload)
    assert inv.store_id == 123


@pytest.mark.parametrize("field", ["on_hand_qty", "on_order_qty"])
def test_inventory_create_non_negative_quantities(field):
    """InventoryCreate enforces non-negative inventory quantities."""
    payload = _valid_inventory_payload()
    payload[field] = -1

    with pytest.raises(ValidationError):
        schemas.InventoryCreate(**payload)


def test_inventory_create_store_id_positive_when_set():
    """InventoryCreate requires store_id, when set, to be positive."""
    payload = _valid_inventory_payload()
    payload["store_id"] = 0

    with pytest.raises(ValidationError):
        schemas.InventoryCreate(**payload)


def test_inventory_create_date_consistency():
    """InventoryCreate enforces effective_date <= end_date."""
    today = date.today()
    payload = _valid_inventory_payload()
    payload["effective_date"] = today
    payload["end_date"] = today - timedelta(days=1)

    with pytest.raises(ValidationError):
        schemas.InventoryCreate(**payload)

These tests assume the following schema names and fields in app/features/data_platform/schemas.py:

  • StoreCreate with fields: code (str with max_length/min_length), name (non-empty str).
  • ProductCreate with fields: sku (str), name (str), base_price (float, ge=0), base_cost (float, ge=0), discount_pct (float between 0 and 1 inclusive).
  • InventoryCreate with fields: sku (str), store_id (Optional[int], must be positive when not None), on_hand_qty and on_order_qty (non-negative numeric), effective_date and end_date (dates with a validator enforcing effective_date <= end_date).

If your actual schema names or fields differ, adjust the imports and field names in these tests accordingly, or add the corresponding *Create schemas and Pydantic field constraints so these tests pass.

@w7-mgfcode w7-mgfcode closed this Jan 26, 2026
@w7-mgfcode w7-mgfcode deleted the feat/prp-2-data-platform-schema branch January 26, 2026 10:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants