sqlym 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sqlym/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ """sqly: SQL-first database access library for Python."""
2
+
3
+ from sqlym._parse import parse_sql
4
+ from sqlym.dialect import Dialect
5
+ from sqlym.escape_utils import escape_like
6
+ from sqlym.exceptions import MappingError, SqlFileNotFoundError, SqlParseError, SqlyError
7
+ from sqlym.loader import SqlLoader
8
+ from sqlym.mapper import ManualMapper, RowMapper, create_mapper
9
+ from sqlym.mapper.column import Column, entity
10
+ from sqlym.parser.twoway import ParsedSQL, TwoWaySQLParser
11
+
12
+ __all__ = [
13
+ "Column",
14
+ "Dialect",
15
+ "ManualMapper",
16
+ "MappingError",
17
+ "ParsedSQL",
18
+ "RowMapper",
19
+ "SqlFileNotFoundError",
20
+ "SqlLoader",
21
+ "SqlParseError",
22
+ "SqlyError",
23
+ "TwoWaySQLParser",
24
+ "create_mapper",
25
+ "entity",
26
+ "escape_like",
27
+ "parse_sql",
28
+ ]
sqlym/_parse.py ADDED
@@ -0,0 +1,36 @@
1
+ """parse_sql 便利関数."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from sqlym.parser.twoway import ParsedSQL, TwoWaySQLParser
8
+
9
+ if TYPE_CHECKING:
10
+ from sqlym.dialect import Dialect
11
+
12
+
13
+ def parse_sql(
14
+ sql: str,
15
+ params: dict[str, Any],
16
+ *,
17
+ placeholder: str = "?",
18
+ dialect: Dialect | None = None,
19
+ ) -> ParsedSQL:
20
+ """SQL をパースする便利関数.
21
+
22
+ Args:
23
+ sql: SQL テンプレート
24
+ params: パラメータ辞書
25
+ placeholder: プレースホルダ形式 ("?", "%s", ":name")
26
+ dialect: RDBMS 方言。指定時は dialect.placeholder を使用する。
27
+
28
+ Returns:
29
+ パース結果
30
+
31
+ Raises:
32
+ ValueError: dialect と placeholder (デフォルト以外) を同時に指定した場合
33
+
34
+ """
35
+ parser = TwoWaySQLParser(sql, placeholder=placeholder, dialect=dialect)
36
+ return parser.parse(params)
sqlym/config.py ADDED
@@ -0,0 +1,7 @@
1
+ """sqly の設定."""
2
+
3
+ # エラーメッセージに SQL 断片を含めるか
4
+ ERROR_INCLUDE_SQL: bool = True
5
+
6
+ # エラーメッセージの言語 ("ja" / "en")
7
+ ERROR_MESSAGE_LANGUAGE: str = "ja"
sqlym/dialect.py ADDED
@@ -0,0 +1,81 @@
1
+ """Dialect enum: RDBMS ごとの SQL 方言定義."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+
7
+
8
+ class Dialect(Enum):
9
+ """RDBMS ごとの SQL 方言.
10
+
11
+ POSTGRESQL と MYSQL は同じプレースホルダ ``%s`` を使用するが、
12
+ 方言固有プロパティが異なるため別メンバーとして定義する。
13
+ """
14
+
15
+ SQLITE = ("sqlite", "?")
16
+ POSTGRESQL = ("postgresql", "%s")
17
+ MYSQL = ("mysql", "%s")
18
+ ORACLE = ("oracle", ":name")
19
+
20
+ def __init__(self, dialect_id: str, placeholder_fmt: str) -> None:
21
+ self._dialect_id = dialect_id
22
+ self._placeholder_fmt = placeholder_fmt
23
+
24
+ @property
25
+ def placeholder(self) -> str:
26
+ """プレースホルダ文字列を返す."""
27
+ return self._placeholder_fmt
28
+
29
+ @property
30
+ def like_escape_chars(self) -> frozenset[str]:
31
+ """LIKE 句でエスケープが必要な特殊文字を返す.
32
+
33
+ SQL 標準の LIKE ワイルドカード文字(``%``, ``_``)と
34
+ エスケープ文字自体(``#``)をエスケープ対象とする。
35
+
36
+ Note:
37
+ Oracle の LIKE ESCAPE 構文では、エスケープ文字の後には
38
+ ``%`` または ``_`` のみ指定可能(ORA-01424)。
39
+ 全角文字(``%``, ``_``)は Oracle のワイルドカードではないため、
40
+ エスケープ対象に含めない。
41
+
42
+ Returns:
43
+ エスケープ対象文字の frozenset
44
+ """
45
+ return frozenset({"#", "%", "_"})
46
+
47
+ @property
48
+ def in_clause_limit(self) -> int | None:
49
+ """IN 句に指定できる要素数の上限を返す.
50
+
51
+ Oracle は 1000 件の制限がある。他の RDBMS は制限なし。
52
+
53
+ Returns:
54
+ 上限値。None は無制限を意味する。
55
+ """
56
+ match self:
57
+ case Dialect.ORACLE:
58
+ return 1000
59
+ case _:
60
+ return None
61
+
62
+ @property
63
+ def backslash_is_escape(self) -> bool:
64
+ """バックスラッシュが文字列リテラル内でエスケープ文字として機能するか.
65
+
66
+ MySQL と PostgreSQL ではデフォルトで True。
67
+ """
68
+ match self:
69
+ case Dialect.MYSQL | Dialect.POSTGRESQL:
70
+ return True
71
+ case _:
72
+ return False
73
+
74
+ @property
75
+ def like_escape_char(self) -> str:
76
+ """LIKE エスケープに使用するエスケープ文字を返す.
77
+
78
+ Returns:
79
+ エスケープ文字(デフォルト: ``#``)
80
+ """
81
+ return "#"
sqlym/escape_utils.py ADDED
@@ -0,0 +1,48 @@
1
+ """エスケープ関連ユーティリティ."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from sqlym.dialect import Dialect
9
+
10
+
11
+ def escape_like(value: str, dialect: Dialect, *, escape_char: str | None = None) -> str:
12
+ """LIKE 句で使用する値の特殊文字をエスケープする.
13
+
14
+ LIKE 句のワイルドカード文字(``%``, ``_``)およびエスケープ文字自体を
15
+ エスケープ処理する。Oracle では全角ワイルドカード(``%``, ``_``)も対象。
16
+
17
+ Args:
18
+ value: エスケープ対象の文字列
19
+ dialect: RDBMS 方言
20
+ escape_char: エスケープ文字(省略時は dialect.like_escape_char を使用)
21
+
22
+ Returns:
23
+ エスケープ処理された文字列
24
+
25
+ Examples:
26
+ >>> from sqlym import Dialect, escape_like
27
+ >>> escape_like("10%off", Dialect.SQLITE)
28
+ '10#%off'
29
+ >>> escape_like("file_name", Dialect.ORACLE)
30
+ 'file#_name'
31
+ >>> escape_like("100%達成", Dialect.ORACLE)
32
+ '100#%達成'
33
+
34
+ Note:
35
+ この関数でエスケープした値を LIKE 句で使用する場合、
36
+ SQL に ``ESCAPE '#'`` 句を追加する必要がある::
37
+
38
+ SELECT * FROM t WHERE name LIKE ? ESCAPE '#'
39
+
40
+ """
41
+ esc = escape_char if escape_char is not None else dialect.like_escape_char
42
+ escape_chars = dialect.like_escape_chars
43
+ result: list[str] = []
44
+ for ch in value:
45
+ if ch in escape_chars:
46
+ result.append(esc)
47
+ result.append(ch)
48
+ return "".join(result)
sqlym/exceptions.py ADDED
@@ -0,0 +1,17 @@
1
+ """sqly例外クラス."""
2
+
3
+
4
+ class SqlyError(Exception):
5
+ """sqlyの基底例外."""
6
+
7
+
8
+ class SqlParseError(SqlyError):
9
+ """SQLパースエラー."""
10
+
11
+
12
+ class MappingError(SqlyError):
13
+ """マッピングエラー."""
14
+
15
+
16
+ class SqlFileNotFoundError(SqlyError):
17
+ """SQLファイルが見つからない."""
sqlym/loader.py ADDED
@@ -0,0 +1,72 @@
1
+ """SqlLoader: SQL ファイルの読み込み."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ from sqlym.exceptions import SqlFileNotFoundError
9
+
10
+ if TYPE_CHECKING:
11
+ from sqlym.dialect import Dialect
12
+
13
+
14
+ class SqlLoader:
15
+ """SQL ファイルの読み込み."""
16
+
17
+ def __init__(self, base_path: str | Path = "sql") -> None:
18
+ self.base_path = Path(base_path)
19
+
20
+ def load(self, path: str, *, dialect: Dialect | None = None) -> str:
21
+ """SQL ファイルを読み込む.
22
+
23
+ dialect が指定された場合、まず RDBMS 固有ファイル(例: ``find.oracle.sql``)を
24
+ 探し、存在しなければ汎用ファイル(例: ``find.sql``)にフォールバックする。
25
+
26
+ Args:
27
+ path: base_path からの相対パス
28
+ dialect: RDBMS 方言。指定時は方言固有ファイルを優先
29
+
30
+ Returns:
31
+ SQL テンプレート文字列
32
+
33
+ Raises:
34
+ SqlFileNotFoundError: ファイルが存在しない場合
35
+
36
+ Examples:
37
+ >>> loader = SqlLoader("sql")
38
+ >>> # sql/find.oracle.sql があれば優先、なければ sql/find.sql
39
+ >>> sql = loader.load("find.sql", dialect=Dialect.ORACLE)
40
+
41
+ """
42
+ base_path = self.base_path.resolve()
43
+
44
+ if dialect is not None:
45
+ dialect_path = self._dialect_specific_path(path, dialect._dialect_id)
46
+ dialect_file_path = (base_path / dialect_path).resolve()
47
+ if self._is_valid_path(base_path, dialect_file_path):
48
+ return dialect_file_path.read_text(encoding="utf-8")
49
+
50
+ file_path = (base_path / path).resolve()
51
+ if not self._is_valid_path(base_path, file_path):
52
+ msg = f"SQL file not found: {file_path}"
53
+ raise SqlFileNotFoundError(msg)
54
+ return file_path.read_text(encoding="utf-8")
55
+
56
+ @staticmethod
57
+ def _is_valid_path(base_path: Path, file_path: Path) -> bool:
58
+ """ファイルパスが有効か(base_path 配下に存在するか)を判定する."""
59
+ if file_path != base_path and base_path not in file_path.parents:
60
+ return False
61
+ return file_path.is_file()
62
+
63
+ @staticmethod
64
+ def _dialect_specific_path(path: str, dialect_id: str) -> str:
65
+ """Dialect 固有のファイルパスを生成する.
66
+
67
+ ``find.sql`` → ``find.oracle.sql`` のように変換する。
68
+ """
69
+ if "." in path:
70
+ base, ext = path.rsplit(".", 1)
71
+ return f"{base}.{dialect_id}.{ext}"
72
+ return f"{path}.{dialect_id}"
@@ -0,0 +1,7 @@
1
+ """sqly マッパーパッケージ."""
2
+
3
+ from sqlym.mapper.factory import create_mapper
4
+ from sqlym.mapper.manual import ManualMapper
5
+ from sqlym.mapper.protocol import RowMapper
6
+
7
+ __all__ = ["ManualMapper", "RowMapper", "create_mapper"]
sqlym/mapper/column.py ADDED
@@ -0,0 +1,40 @@
1
+ """Column アノテーションと @entity デコレータ."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class Column:
9
+ """カラム名を指定するアノテーション."""
10
+
11
+ def __init__(self, name: str) -> None:
12
+ self.name = name
13
+
14
+ def __repr__(self) -> str:
15
+ return f"Column({self.name!r})"
16
+
17
+
18
+ def entity(
19
+ cls: type | None = None,
20
+ *,
21
+ column_map: dict[str, str] | None = None,
22
+ naming: str = "as_is",
23
+ ) -> Any:
24
+ """エンティティデコレータ.
25
+
26
+ Args:
27
+ cls: デコレート対象クラス
28
+ column_map: フィールド名→カラム名のマッピング
29
+ naming: 命名規則 ("as_is", "snake_to_camel", "camel_to_snake")
30
+
31
+ """
32
+
33
+ def decorator(cls: type) -> type:
34
+ cls.__column_map__ = column_map or {} # type: ignore[attr-defined]
35
+ cls.__column_naming__ = naming # type: ignore[attr-defined]
36
+ return cls
37
+
38
+ if cls is not None:
39
+ return decorator(cls)
40
+ return decorator
@@ -0,0 +1,97 @@
1
+ """DataclassMapper: dataclass 用の自動マッパー."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import fields, is_dataclass
7
+ from typing import Annotated, Any, ClassVar, get_args, get_origin, get_type_hints
8
+
9
+ from sqlym.mapper.column import Column
10
+
11
+
12
+ class DataclassMapper:
13
+ """Dataclass 用の自動マッパー."""
14
+
15
+ _mapping_cache: ClassVar[dict[type, dict[str, str]]] = {}
16
+
17
+ def __init__(self, entity_cls: type) -> None:
18
+ if not is_dataclass(entity_cls):
19
+ msg = f"{entity_cls} is not a dataclass"
20
+ raise TypeError(msg)
21
+ self.entity_cls = entity_cls
22
+ self._mapping = self._get_mapping(entity_cls)
23
+
24
+ @classmethod
25
+ def _get_mapping(cls, entity_cls: type) -> dict[str, str]:
26
+ """フィールド名→カラム名のマッピングを取得(キャッシュ付き)."""
27
+ if entity_cls not in cls._mapping_cache:
28
+ cls._mapping_cache[entity_cls] = cls._build_mapping(entity_cls)
29
+ return cls._mapping_cache[entity_cls]
30
+
31
+ @classmethod
32
+ def _build_mapping(cls, entity_cls: type) -> dict[str, str]:
33
+ """フィールド名→カラム名のマッピングを構築."""
34
+ hints = get_type_hints(entity_cls, include_extras=True)
35
+ column_map: dict[str, str] = getattr(entity_cls, "__column_map__", {})
36
+ naming: str = getattr(entity_cls, "__column_naming__", "as_is")
37
+
38
+ mapping: dict[str, str] = {}
39
+
40
+ for f in fields(entity_cls):
41
+ field_name = f.name
42
+
43
+ # 1. Annotated[..., Column("X")] をチェック
44
+ type_hint = hints.get(field_name)
45
+ if type_hint and get_origin(type_hint) is Annotated:
46
+ for arg in get_args(type_hint)[1:]:
47
+ if isinstance(arg, Column):
48
+ mapping[field_name] = arg.name
49
+ break
50
+
51
+ if field_name in mapping:
52
+ continue
53
+
54
+ # 2. column_map をチェック
55
+ if field_name in column_map:
56
+ mapping[field_name] = column_map[field_name]
57
+ continue
58
+
59
+ # 3. naming ルール適用
60
+ if naming == "snake_to_camel":
61
+ mapping[field_name] = cls._to_camel(field_name)
62
+ elif naming == "camel_to_snake":
63
+ mapping[field_name] = cls._to_snake(field_name)
64
+ else:
65
+ mapping[field_name] = field_name
66
+
67
+ return mapping
68
+
69
+ @staticmethod
70
+ def _to_camel(name: str) -> str:
71
+ """Snake_case → camelCase."""
72
+ components = name.split("_")
73
+ return components[0] + "".join(x.title() for x in components[1:])
74
+
75
+ @staticmethod
76
+ def _to_snake(name: str) -> str:
77
+ """CamelCase → snake_case."""
78
+ return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
79
+
80
+ def map_row(self, row: dict[str, Any]) -> Any:
81
+ """1行をエンティティに変換."""
82
+ row_lower = {k.lower(): v for k, v in row.items()}
83
+ kwargs: dict[str, Any] = {}
84
+ for field_name, col_name in self._mapping.items():
85
+ if col_name in row:
86
+ kwargs[field_name] = row[col_name]
87
+ elif col_name.lower() in row_lower:
88
+ kwargs[field_name] = row_lower[col_name.lower()]
89
+ elif field_name in row:
90
+ kwargs[field_name] = row[field_name]
91
+ elif field_name.lower() in row_lower:
92
+ kwargs[field_name] = row_lower[field_name.lower()]
93
+ return self.entity_cls(**kwargs)
94
+
95
+ def map_rows(self, rows: list[dict[str, Any]]) -> list[Any]:
96
+ """複数行をエンティティのリストに変換."""
97
+ return [self.map_row(row) for row in rows]
@@ -0,0 +1,50 @@
1
+ """create_mapper ファクトリ関数."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import is_dataclass
6
+ from typing import Any
7
+
8
+ from sqlym.mapper.manual import ManualMapper
9
+ from sqlym.mapper.protocol import RowMapper
10
+
11
+
12
+ def create_mapper(
13
+ entity_cls: type,
14
+ *,
15
+ mapper: Any = None,
16
+ ) -> Any:
17
+ """マッパーを生成する.
18
+
19
+ Args:
20
+ entity_cls: エンティティクラス
21
+ mapper: RowMapper インスタンス、Callable、または None(自動判定)
22
+
23
+ Returns:
24
+ RowMapper プロトコルを満たすマッパー
25
+
26
+ Raises:
27
+ TypeError: マッパーを自動判定できない場合
28
+
29
+ """
30
+ if mapper is not None:
31
+ if isinstance(mapper, RowMapper):
32
+ return mapper
33
+ if callable(mapper):
34
+ return ManualMapper(mapper)
35
+
36
+ if is_dataclass(entity_cls):
37
+ from sqlym.mapper.dataclass import DataclassMapper
38
+
39
+ return DataclassMapper(entity_cls)
40
+
41
+ if hasattr(entity_cls, "model_validate"):
42
+ from sqlym.mapper.pydantic import PydanticMapper
43
+
44
+ return PydanticMapper(entity_cls)
45
+
46
+ msg = (
47
+ f"Cannot create mapper for {entity_cls}. "
48
+ f"Use dataclass, Pydantic, or provide a custom mapper."
49
+ )
50
+ raise TypeError(msg)
sqlym/mapper/manual.py ADDED
@@ -0,0 +1,21 @@
1
+ """ManualMapper: ユーザー提供の関数をラップするマッパー."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+
9
+ class ManualMapper:
10
+ """ユーザー提供の関数をラップするマッパー."""
11
+
12
+ def __init__(self, func: Callable[[dict[str, Any]], Any]) -> None:
13
+ self._func = func
14
+
15
+ def map_row(self, row: dict[str, Any]) -> Any:
16
+ """1行をエンティティに変換."""
17
+ return self._func(row)
18
+
19
+ def map_rows(self, rows: list[dict[str, Any]]) -> list[Any]:
20
+ """複数行をエンティティのリストに変換."""
21
+ return [self._func(row) for row in rows]
@@ -0,0 +1,20 @@
1
+ """RowMapper プロトコル定義."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Protocol, TypeVar, runtime_checkable
6
+
7
+ T = TypeVar("T")
8
+
9
+
10
+ @runtime_checkable
11
+ class RowMapper(Protocol[T]):
12
+ """マッパーのインターフェース."""
13
+
14
+ def map_row(self, row: dict[str, Any]) -> T:
15
+ """1行をエンティティに変換."""
16
+ ...
17
+
18
+ def map_rows(self, rows: list[dict[str, Any]]) -> list[T]:
19
+ """複数行をエンティティのリストに変換."""
20
+ ...
@@ -0,0 +1,23 @@
1
+ """PydanticMapper: Pydantic BaseModel 用のマッパー."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class PydanticMapper:
9
+ """Pydantic BaseModel 用のマッパー."""
10
+
11
+ def __init__(self, entity_cls: type) -> None:
12
+ if not hasattr(entity_cls, "model_validate"):
13
+ msg = f"{entity_cls} is not a Pydantic BaseModel"
14
+ raise TypeError(msg)
15
+ self.entity_cls = entity_cls
16
+
17
+ def map_row(self, row: dict[str, Any]) -> Any:
18
+ """1行をエンティティに変換."""
19
+ return self.entity_cls.model_validate(row)
20
+
21
+ def map_rows(self, rows: list[dict[str, Any]]) -> list[Any]:
22
+ """複数行をエンティティのリストに変換."""
23
+ return [self.map_row(row) for row in rows]
File without changes
@@ -0,0 +1,36 @@
1
+ """行単位処理ユニット(Clione-SQL Rule 1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass
9
+ class LineUnit:
10
+ """1行を表すユニット(Clione-SQL Rule 1)."""
11
+
12
+ line_number: int
13
+ """元のSQL内での行番号."""
14
+
15
+ original: str
16
+ """元の行文字列."""
17
+
18
+ indent: int
19
+ """インデント深さ."""
20
+
21
+ content: str
22
+ """インデント除去後の内容."""
23
+
24
+ children: list[LineUnit] = field(default_factory=list)
25
+ """子LineUnitのリスト."""
26
+
27
+ parent: LineUnit | None = None
28
+ """親LineUnit."""
29
+
30
+ removed: bool = False
31
+ """削除フラグ."""
32
+
33
+ @property
34
+ def is_empty(self) -> bool:
35
+ """空行かどうか."""
36
+ return self.indent < 0 or not self.content.strip()