Skip to content
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

How can I make DateTimeTZRange work? #235

Open
8 tasks done
arielpontes opened this issue Feb 4, 2022 · 4 comments
Open
8 tasks done

How can I make DateTimeTZRange work? #235

arielpontes opened this issue Feb 4, 2022 · 4 comments
Labels
question Further information is requested

Comments

@arielpontes
Copy link

First Check

  • I added a very descriptive title to this issue.
  • I used the GitHub search to find a similar issue and didn't find it.
  • I searched the SQLModel documentation, with the integrated search.
  • I already searched in Google "How to X in SQLModel" and didn't find any information.
  • I already read and followed all the tutorial in the docs and didn't find an answer.
  • I already checked if it is not related to SQLModel but to Pydantic.
  • I already checked if it is not related to SQLModel but to SQLAlchemy.

Commit to Help

  • I commit to help with one of those options 👆

Example Code

from psycopg2.extras import DateTimeTZRange
from pydantic import conlist
from sqlalchemy import Column
from sqlalchemy.dialects.postgresql import TSTZRANGE
from sqlmodel import Field

from app.models.base import BaseModel

class MyModel(BaseModel, table=True):
    # time_range: conlist(datetime, min_items=2, max_items=2) = Field(
    time_range: DateTimeTZRange = Field(
        sa_column=Column(TSTZRANGE()), nullable=False, index=False
    )

    class Config:
        arbitrary_types_allowed = True

Description

I have a model that has a DateTimeTZRange field. When I try to save an instance of this model, I get the following error:

sqlalchemy.exc.DBAPIError: (sqlalchemy.dialects.postgresql.asyncpg.Error) <class 'asyncpg.exceptions.DataError'>: invalid input for query argument $1: DateTimeTZRange(datetime.datetime(2019, ... (list, tuple or Range object expected (got type <class 'psycopg2._range.DateTimeTZRange'>))
[SQL: INSERT INTO my_model (time_range) VALUES (%s) RETURNING my_model.id]
[parameters: (DateTimeTZRange(datetime.datetime(2019, 4, 2, 23, 0, tzinfo=<UTC>), datetime.datetime(2019, 4, 2, 23, 30, tzinfo=<UTC>), '[)'))]

When I use conlist rather than DateTimeTZRange, I don't get errors but the new objects always have None in their time_range. I couldn't find any documentation on how to use custom PostgreSQL fields in SQLModel, and the documentation I found on sqlalchemy doesn't seem to work smoothly on SQLModel.

Operating System

macOS

Operating System Details

No response

SQLModel Version

0.0.4

Python Version

3.9.9

Additional Context

No response

@arielpontes arielpontes added the question Further information is requested label Feb 4, 2022
@byrman
Copy link
Contributor

byrman commented Feb 8, 2022

I experienced no problems saving instances with the conlist version of your code. How did you initialize your model? This, for example, worked for me: MyModel(time_range=[datetime.now(timezone.utc), datetime.max])

@amacfie
Copy link

amacfie commented Apr 29, 2022

Which version of PostgreSQL are you using?

@Zaffer
Copy link

Zaffer commented Jun 21, 2022

This is a bit tricky due to Pydantic need validators etc. Basically the DateTimeTZRange type does not have the required method to convert its data to a dict, so you need to do that manually.

  • The code I made below adds to Pydantic's ENCODERS_BY_TYPE
  • It then creates a sub-class of DateTimeTZRange to include required validators
  • This subclass also modifies the schema for presentation in the api docs correctly (with example)
  • Because you are sub-classing the type you don't need arbitrary_types_allowed = True or any class Config:
from psycopg2.extras import DateTimeTZRange as DateTimeTZRangeBase
from sqlalchemy.dialects.postgresql import TSTZRANGE
from sqlmodel import (
    Column,
    Field,
    Identity,
    SQLModel,
)

from pydantic.json import ENCODERS_BY_TYPE

ENCODERS_BY_TYPE |= {DateTimeTZRangeBase: str}


class DateTimeTZRange(DateTimeTZRangeBase):
    @classmethod
    def __get_validators__(cls):
        yield cls.validate

    @classmethod
    def validate(cls, v):
        if isinstance(v, str):
            lower = v.split(", ")[0][1:].strip().strip()
            upper = v.split(", ")[1][:-1].strip().strip()
            bounds = v[:1] + v[-1:]
            return DateTimeTZRange(lower, upper, bounds)
        elif isinstance(v, DateTimeTZRangeBase):
            return v
        raise TypeError("Type must be string or DateTimeTZRange")

    @classmethod
    def __modify_schema__(cls, field_schema):
        field_schema.update(type="string", example="[2022,01,01, 2022,02,02)")


class EventBase(SQLModel):
    __tablename__ = "event"
    timestamp_range: DateTimeTZRange = Field(
        sa_column=Column(
            TSTZRANGE(),
            nullable=False,
        ),
    )


class Event(EventBase, table=True):
    id: int | None = Field(
        default=None,
        sa_column_args=(Identity(always=True),),
        primary_key=True,
        nullable=False,
    )

Pydantic issues:

Pydantic reading:

@shaug
Copy link

shaug commented Nov 30, 2023

We had a similar question for our project, but for a DateRange adapter instead.

We adapted @Zaffer's suggestion, and it worked splendidly for persisting a value. But when the instance was refreshed/reloaded from the db, the field's resulting value on the SQLModel-derived instance was not the expected custom DateRange subclass, but rather a sqlalchemy.dialects.postgresql.ranges.Range instance (i.e., an instance of the DATERANGE used to specify the Column type).

I'm still trying to understand the mechanisms used to hydrate a row of SQL data into a Pydantic instance, but I would've expected it would attempt to cover the loaded data into the specified type on the SQLModel class.

Is this a bug, a misconfiguration, or a misunderstanding of how SQLModel/Pydantic/SQLAlchemy work?

from datetime import date

from psycopg2.extras import DateRange as DateRangeBase
from sqlalchemy.dialects.postgresql import DATERANGE

def _to_date(value: str) -> date:
    vals = map(
        int, value.strip().replace("[", "").replace(")", "").split("-", maxsplit=2)
    )
    return date(*vals)

class DateRange(DateRangeBase):
    """
    A range of dates, used for persisting in a PostgreSQL database.
    """

    @classmethod
    def __get_validators__(cls):
        yield cls.validate

    @classmethod
    def validate(cls, v):
        if isinstance(v, DateRangeBase):
            return v
        if isinstance(v, str):
            lower, upper = map(_to_date, v.split(sep=",", maxsplit=1))
            bounds = v[:1] + v[-1:]
            return DateRange(lower, upper, bounds)
        raise TypeError("Type must be a string or DateRange")

    @classmethod
    def __modify_schema__(cls, field_schema):
        field_schema.update(type="string", example="[2022-01-01,2022-02-02)")

class EventBase(SQLModel):
    date_range: DateRange = Field(sa_column=Column(DATERANGE(), nullable=False))

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
question Further information is requested
Projects
None yet
Development

No branches or pull requests

5 participants