2024年1月16日火曜日

SQLAlchemy の cast と type_coerce の使い分けとサンプルコード

SQLAlchemy の cast と type_coerce の使い分けとサンプルコード

概要

ほぼ違いはありませんがそれぞれのサンプルコードを紹介します
基本的には type_coerce を使うのが良いかなと思います

環境

  • macOS 14.2.1
  • Python 3.11.6
  • sqlalchemy 2.0.25

テーブル準備

CREATE TABLE user (id int(11) NOT NULL AUTO_INCREMENT, profile json DEFAULT NULL, PRIMARY KEY (id));

INSERT INTO user VALUES (null, '{"name":"hawk"}');
INSERT INTO user VALUES (null, '{"name":"snowlog"}');
INSERT INTO user VALUES (null, '{"name":"hawksnowlog"}');

cast サンプルコード

from typing import Any

from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
from sqlalchemy.schema import Column
from sqlalchemy.sql.expression import func
from sqlalchemy.types import JSON, String

engine = create_engine("mysql+pymysql://root@localhost/test?charset=utf8mb4")
SessionClass = sessionmaker(engine)
db_session = SessionClass()

Base = declarative_base()


class User(Base):

    __tablename__ = 'user'

    id = Column(String(32), primary_key=True)
    profile = Column(JSON())


class UserTable():

    def select_all(self):
        return db_session.query(User).all()

    def upsert(self, id: int, profile: dict[str, Any]):
        user_query = db_session.query(User).filter(User.id == id)
        for key, value in profile.items():
            if isinstance(value, list) or isinstance(value, dict):
                user_query.update(
                    {"profile": func.json_set(
                        User.profile,
                        "$." + key,
                        func.cast(value, JSON))}, synchronize_session='fetch')
                db_session.commit()
            else:
                user_query.update(
                    {"profile": func.json_set(
                        User.profile,
                        "$." + key,
                        value)}, synchronize_session='fetch')
                db_session.commit()


if __name__ == '__main__':
    user_table = UserTable()
    user_table.upsert(1, {"name": "hoge", "langs": ["python", "ruby"]})
    user_table.upsert(2, {"name": "fuga", "age": 1})
    records = user_table.select_all()
    for r in records:
        print(r.id)
        print(r.profile)

おまけ func.cast の場合

from typing import Any

from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
from sqlalchemy.schema import Column
from sqlalchemy.sql.expression import cast, func
from sqlalchemy.types import JSON, String

engine = create_engine("mysql+pymysql://root@localhost/test?charset=utf8mb4")
SessionClass = sessionmaker(engine)
db_session = SessionClass()

Base = declarative_base()


class User(Base):

    __tablename__ = 'user'

    id = Column(String(32), primary_key=True)
    profile = Column(JSON())


class UserTable():

    def select_all(self):
        return db_session.query(User).all()

    def upsert(self, id: int, profile: dict[str, Any]):
        user_query = db_session.query(User).filter(User.id == id)
        for key, value in profile.items():
            if isinstance(value, list) or isinstance(value, dict):
                user_query.update(
                    {"profile": func.json_set(
                        User.profile,
                        "$." + key,
                        cast(value, JSON))}, synchronize_session='fetch')
                db_session.commit()
            else:
                user_query.update(
                    {"profile": func.json_set(
                        User.profile,
                        "$." + key,
                        value)}, synchronize_session='fetch')
                db_session.commit()


if __name__ == '__main__':
    user_table = UserTable()
    user_table.upsert(1, {"name": "hoge", "langs": ["python", "swift"]})
    user_table.upsert(2, {"name": "fuga", "age": 1})
    records = user_table.select_all()
    for r in records:
        print(r.id)
        print(r.profile)

type_coerce サンプルコード

from typing import Any

from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
from sqlalchemy.schema import Column
from sqlalchemy.sql.expression import func, type_coerce
from sqlalchemy.types import JSON, String

engine = create_engine("mysql+pymysql://root@localhost/test?charset=utf8mb4")
SessionClass = sessionmaker(engine)
db_session = SessionClass()

Base = declarative_base()


class User(Base):

    __tablename__ = 'user'

    id = Column(String(32), primary_key=True)
    profile = Column(JSON())


class UserTable():

    def select_all(self):
        return db_session.query(User).all()

    def upsert(self, id: int, profile: dict[str, Any]):
        user_query = db_session.query(User).filter(User.id == id)
        for key, value in profile.items():
            if isinstance(value, list) or isinstance(value, dict):
                user_query.update(
                    {"profile": func.json_set(
                        User.profile,
                        "$." + key,
                        type_coerce(value, JSON))}, synchronize_session='fetch')
                db_session.commit()
            else:
                user_query.update(
                    {"profile": func.json_set(
                        User.profile,
                        "$." + key,
                        value)}, synchronize_session='fetch')
                db_session.commit()


if __name__ == '__main__':
    user_table = UserTable()
    user_table.upsert(1, {"name": "hoge", "langs": ["python", "swift"]})
    user_table.upsert(2, {"name": "fuga", "age": 1})
    records = user_table.select_all()
    for r in records:
        print(r.id)
        print(r.profile)

ちょっと解説

結果の違いはどちらも同じです
どちらも同じように動きます

func に type_coerce はありませんが func から cast はコールできます
公式での type_coerce の説明は「Associate a SQL expression with a particular type, without rendering CAST.」

SQL 側の機能ではなく Python 側の機能だけで型変換を行う感じかなと思います
公式を読む限りでは cast の代替として type_coerce を使うべきだとあるので基本的には type_coerce を使うのが良いかなと思います

参考サイト

2024年1月15日月曜日

SQLAlchemy2 では Mapped カラムを使ってモデルを定義する

SQLAlchemy2 では Mapped カラムを使ってモデルを定義する

概要

Mapped カラムを使うとより Python のデータクラスっぽくモデルを定義することができます

また declarative_base の使い方も変わったので v2 にあった記載方法を紹介します

環境

  • macOS 11.7.10
  • Python 3.11.6
  • sqlalchemy 2.0.25

サンプルコード

from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
from sqlalchemy.types import JSON

engine = create_engine("mysql+pymysql://root@localhost/test?charset=utf8mb4")


class Base(DeclarativeBase):
    pass


class User(Base):

    __tablename__ = 'user'

    id: Mapped[int] = mapped_column(primary_key=True)
    profile: Mapped[dict] = mapped_column(JSON())


class UserTable():
    def __init__(self, session: Session):
        self.session = session

    def select_all(self):
        return self.session.query(User).all()


if __name__ == '__main__':
    with Session(engine) as session:
        user_table = UserTable(session)
        records = user_table.select_all()
        for r in records:
            print(r.id)
            print(r.profile)

解説

DeclarativeBase は直接使用できないの必ず DeclarativeBase を継承したベースクラスを作成してそのベースクラスを元にモデルを定義する必要があります

モデルは Mapped と一緒に型を使ってタイプヒトっぽいく記載します
そしてカラムのオプション情報などを mapped_column を使って定義します
単純な文字列を管理するクラスであれば mapped_column を使ったオプション情報は不要です

session も session_maker からは生成せずに直接クラスに engine 情報を渡すことで生成できます
基本は with セッションを使えば自動でクローズしてくれるので with と併用しましょう

JSON を TypedDict に自動バインドする

sqlalchemy v2 では json は dict として扱うので受け取るときに TypedDict として受け取ることもできます

できれば dataclass などのクラスに変換してほしかったのですが単純にやってみたところダメそうだったので自力でやるか他の方法があるのかもしれません

from typing import TypedDict

from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
from sqlalchemy.types import JSON

engine = create_engine("mysql+pymysql://root@localhost/test?charset=utf8mb4")


class Profile(TypedDict):
    name: str


class Base(DeclarativeBase):
    pass


class User(Base):

    __tablename__ = 'user'

    id: Mapped[int] = mapped_column(primary_key=True)
    profile: Mapped[Profile] = mapped_column(JSON())


class UserTable():
    def __init__(self, session: Session):
        self.session = session

    def select_all(self):
        return self.session.query(User).all()


if __name__ == '__main__':
    with Session(engine) as session:
        user_table = UserTable(session)
        records = user_table.select_all()
        for r in records:
            print(r.id)
            print(r.profile["name"])

参考: v1 でのサンプルコード

ライブラリが v2 でも互換があるので動作しますがそのうち使えなくなるかもしれないです

from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
from sqlalchemy.schema import Column
from sqlalchemy.types import JSON, String

engine = create_engine("mysql+pymysql://root@localhost/test?charset=utf8mb4")
SessionClass = sessionmaker(engine)
db_session = SessionClass()

Base = declarative_base()


class User(Base):

    __tablename__ = 'user'

    id = Column(String(32), primary_key=True)
    profile = Column(JSON())


class UserTable():

    def select_all(self):
        return db_session.query(User).all()


if __name__ == '__main__':
    user_table = UserTable()
    records = user_table.select_all()
    for r in records:
        print(r.id)
        print(r.profile)

最後に

SQLAlchemy v2 では Mapped を使ってモデルをタイプヒントっぽく定義できるようになっています

JSON の扱い方はもう少し検討が必要そうです

参考サイト

2024年1月12日金曜日

flask-pydantic が v2 に対応していたので試してみた

flask-pydantic が v2 に対応していたので試してみた

概要

過去に使ったときは pydantic v2 に対応していませんでした
flask-pydantic 0.12.0 で対応したようなので使ってみました

環境

  • macOS 11.7.10
  • Python 3.11.6
    • flask 3.0.0
    • pydantic 2.5.3
    • flask-pydantic 0.12.0

サンプルコード

from typing import Optional

from flask import Flask, jsonify
from flask_pydantic import ValidationError, validate
from pydantic import BaseModel, field_validator

app = Flask(__name__)
app.config["FLASK_PYDANTIC_VALIDATION_ERROR_RAISE"] = True


class ResponseModel(BaseModel):
    id: int
    age: int
    name: str
    nickname: Optional[str]


class RequestBodyModel(BaseModel):
    name: str
    nickname: Optional[str]

    @field_validator("name")
    def check_name(cls, v, values, **kwargs):
        if v != "hawksnowlog":
            raise ValueError()
        return v


@app.route("/", methods=["POST"])
@validate()
def post(body: RequestBodyModel):
    name = body.name
    nickname = body.nickname
    return ResponseModel(name=name, nickname=nickname, id=0, age=1000)


@app.errorhandler(ValidationError)
def handle_bad_request(_):
    return jsonify({"error": "validation_error"}), 400


app.register_error_handler(400, handle_bad_request)

所感

開発はまだまだ途中という感じかなと思います
エラーハンドリングの部分が微妙で今回 FLASK_PYDANTIC_VALIDATION_ERROR_RAISE というフラグを True にしています
これがない状態で ValueError を投げると json にシリアライズする部分でエラーになり json のエラー構文が返ってきません
なので flask-pydantic 側でハンドリングはせずにアプリ側でハンドリングを実装してあげる必要があります
これに関してはそのうち直る可能性はありそうですがそれ以外でもまだバグがありそうなので v2 対応はまだ様子見なのかもしれません

参考サイト

2024年1月11日木曜日

Flask で pydantic を使う方法を考える

Flask で pydantic を使う方法を考える

概要

過去にflask-pydanticを使った方法を紹介しました
しかし flask-pydantic は V2 に対応していません
今回は flask-pydantic なしで Flask + pydantic を連携する方法を考えます

環境

  • macOS 11.7.10
  • Python 3.11.6
    • flask 3.0.0
    • pydantic 2.5.3

案1: ルーティング内の先頭でモデルを使用する

from flask import Flask, jsonify, request
from pydantic import BaseModel, field_validator

app = Flask(__name__)


class User(BaseModel):
    name: str

    @field_validator("name")
    @classmethod
    def validate_name(cls, v):
        if v != "hawk":
            raise ValueError()
        return v


@app.route("/", methods=["POST"])
def test():
    data = request.json
    if data is None:
        raise ValueError()
    user = User(**data)
    return jsonify(user.model_dump())

メリット

  • 簡単、明瞭、特に何も考えず先頭で変換するだけなので楽

デメリット

  • 変換する部分の実装を忘れそう
  • request.json から変換しているが他にも query や path などをモデルに変換する部分の考慮しなければならない

案2: デコレータを使い引数で受け取る

from flask import Flask, jsonify, request
from pydantic import BaseModel, field_validator

app = Flask(__name__)


class User(BaseModel):
    name: str

    @field_validator("name")
    @classmethod
    def validate_name(cls, v):
        if v != "hawk":
            raise ValueError()
        return v


def validate(func):
    def wrapper(*args, **kwargs):
        data = request.json
        if data is None:
            raise ValueError()
        user = User(**data)
        return func(user, *args, **kwargs)

    return wrapper


@app.route("/", methods=["POST"])
@validate
def test(user: User):
    return jsonify(user.model_dump())

メリット

  • ルーティング側での変換が不要になるのでコードがきれいになる
  • 引数で受け取れてかつ検証済みのオブジェクトなので安全に扱える

デメリット

  • デコレータが肥大化しないように気をつける必要がある
    • モデルごとにデコレータを追加する必要がないようにする必要がある
  • request.json から変換しているが他にも query や path などをモデルに変換する部分の考慮しなければならない

最後に

頑張れば何とかなりそうだけどコードが複雑化しないように工夫する必要がありそうです
そのあたりを吸収してくれてくれているのが flask-pydantic なので最悪ライブラリを自分でメンテするのもありなのかもしれません

2024年1月9日火曜日

iPhone6 が何もしていないのにバッテリーが減り続ける問題に対応した

iPhone6 が何もしていないのにバッテリーが減り続ける問題に対応した

概要

バッテリーを交換する前からバッテリーの減りが速かったのですが原因不明で放置していたので対応してみました

環境

  • iPhone6 iOS 12.5.7

アップデート関連をオフにする

  • システムの自動アップグレードオフ
  • アプリのバックグランド更新なし

位置情報関連をオフ

  • 探すアプリの位置情報を拒否

ネットワーク関連をオフにする

  • Bluetooth オフ
  • wifi オフ

てっとり早く対応する場合はフライトモードをオンにしても OK です
ただ個別にオンにしている場合はネットワークに接続した瞬間重くなりバッテリーが減る可能性があるかもです

省電力モードをオンにする

気休めかもですがやっておいて損はないです

タッチIDのコネクタが外れている

自分はこれが結構でかかったです
バッテリーを交換した際にタッチIDのコネクタがうまくハマっておらず iPhone を起動するたびに「タッチIDがアクティブできません」的なエラーが出てきていました

タッチIDがなくても iPhone の動作的には問題ないので放置していたのでコネクタが接続されているか定期的にチェックしているようでそれが原因でバッテリーの消耗が激しくなっていたようです

正しくコネクタを接続し直したところバッテリーの減りが少なくなりました

最後に

使うときにフライトモードを毎回オンオフしないといけないのが非常に面倒なので個別でネットワーク関連をオフにするほうがいいかもしれません

2024年1月8日月曜日

iPhone6 のバッテリー交換

iPhone6 のバッテリー交換

概要

まだ使えるかなと思ってバッテリーを交換しました
手順や感想をメモしておきます

道具

感想

  • iPhoneXR の5倍は簡単
  • 相変わらずバッテリーのテープを剥がすのは無理ゲー

前準備

電源のOFFを行っておきましょう

フロントパネルを開ける

手前のネジ2つを外します
その後フロントパネルを開きます
吸盤で開かない場合は隙間にカッターなど差し込んでテコで開けます
隙間ができたらヘラを押し込んで周りを開けていきます

フロントパネル取り外し

右上の保護パネルをプラスドライバで外します
コネクタを外せばフロントパネルが外せます
コネクタは4箇所あります

バッテリー保護パネル取り外し

ネジは2箇所、コネクタ1箇所です

バイブレータ取り外し

ネジは2箇所です

バッテリー固定テープ引き剥がし

これが一番大変というか無理ゲーなのでテーブが取れた場合は無理やりバッテリーを取り外しましょう

バッテリー取り外し

自分はテープを剥がすのが無理だったのでヘラを横から入れてテコの原理で少しずつ剥がしました
縦ではなく横からやるのもポイントです
iPhoneXR で学んだのでゆっくり慎重にテコの力だけで剥がすときれいにはがせます (ごしごし削ったりしたらダメ)

バッテリー交換

新しいバッテリーに固定テープを貼りコネクタを取り付ければ OK です
あとは逆をたどります

最後に

30分くらいで完了しました
あとバッテリーの状態は交換後のやつでは確認できませんでした (これは iPhoneXR も同様、おそらく互換品ではバッテリーの状態が取得できない)

参考サイト

2023年12月25日月曜日

emacs の lsp-mode で The following clients were selected based on priority エラー

emacs の lsp-mode で The following clients were selected based on priority エラー

概要

emacs + lsp-mode でうまく lsp サーバが起動できない場合の対処方法を紹介します

環境

  • macOS 11.7.10
  • emacs 29.1
  • lsp-mode 20231223.811

状況

emacs で ruby ファイルを起動した際に lsp サーバが見つからずに以下のようなエラーになる場合があります

Command "steep langserver --log-level warn" is not present on the path.                                                                      
Command "stree lsp" is not present on the path.
Command "ruby-lsp" is not present on the path.
Command "srb typecheck --lsp --disable-watchman" is not present on the path.
Command "bundle exec solargraph stdio" is present on the path.
Command "semgrep lsp" is not present on the path.
Command "rubocop --lsp" is not present on the path.
Command "steep langserver --log-level warn" is not present on the path.
Command "stree lsp" is not present on the path.
Command "ruby-lsp" is not present on the path.
Command "srb typecheck --lsp --disable-watchman" is not present on the path.
Command "bundle exec solargraph stdio" is present on the path.
Command "semgrep lsp" is not present on the path.
Command "rubocop --lsp" is not present on the path.
Found the following clients for /Users/hawk/data/repo/ruby-fndb/rank/app.rb: (server-id ruby-ls, priority -1)
The following clients were selected based on priority: (server-id ruby-ls, priority -1)

よくある対処方法としては emacs を起動しているシェルで PATH に solargraph や rubocop などがない場合に emacs から lsp が起動できないケースがありますが今回はしっかり PATH に通っている状態です

ログをよく見るとコマンドのチェックが二回行われています

原因

emacs に追加した lsp セッションパス内に複数の Gemfile があるとその配下全てでコマンドのチェックを行うため親ディレクトリなどに Gemfile があると 2 つの solargraph コマンドが見つかりどちらを起動したらいいのかわからないため該当のエラーになります

対応方法

一旦セッションファイルを作成して再度正しいプロジェクトのパスでセッションを登録しましょう

これで追加する際に親プロジェクトからではなく子プロジェクトを直接登録しましょう
以下の場合はドットを入力します

  • rm ~/.emacs.d/.lsp-session-v1
  • emacs app.rb
app.rb is not part of any project.                                                                                                           
                                                                                                                                             
i ==> Import project root ~/data/repo/ruby-fndb/                                                                                             
I ==> Import project by selecting root directory interactively                                                                               
. ==> Import project at current directory /Users/hawk/data/repo/ruby-fndb/rank/                                                      
d ==> Do not ask again for the current project by adding ~/data/repo/ruby-fndb/ to lsp-session-folders-blocklist                             
D ==> Do not ask again for the current project by selecting ignore path interactively                                                        
n ==> Do nothing: ask again when opening other files from the current project                                                                
                                                                                                                                             
Select action: .

これで再度 lsp が起動されエラーにならないことを確認します

最後に

emacs + lsp で起動しない場合は大抵の場合コマンドがインストールされていなかったり参照できなかったりが多いです
今回は参照はできるが複数の lsp サーバコマンドがある場合のエラーになります

ちなみに lsp-mode の ruby モードだと最優先で実行されるのは現状だと solargraph のようです