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 +28 -0
- sqlym/_parse.py +36 -0
- sqlym/config.py +7 -0
- sqlym/dialect.py +81 -0
- sqlym/escape_utils.py +48 -0
- sqlym/exceptions.py +17 -0
- sqlym/loader.py +72 -0
- sqlym/mapper/__init__.py +7 -0
- sqlym/mapper/column.py +40 -0
- sqlym/mapper/dataclass.py +97 -0
- sqlym/mapper/factory.py +50 -0
- sqlym/mapper/manual.py +21 -0
- sqlym/mapper/protocol.py +20 -0
- sqlym/mapper/pydantic.py +23 -0
- sqlym/parser/__init__.py +0 -0
- sqlym/parser/line_unit.py +36 -0
- sqlym/parser/tokenizer.py +117 -0
- sqlym/parser/twoway.py +516 -0
- sqlym-0.1.0.dist-info/METADATA +260 -0
- sqlym-0.1.0.dist-info/RECORD +22 -0
- sqlym-0.1.0.dist-info/WHEEL +4 -0
- sqlym-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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}"
|
sqlym/mapper/__init__.py
ADDED
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]
|
sqlym/mapper/factory.py
ADDED
|
@@ -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]
|
sqlym/mapper/protocol.py
ADDED
|
@@ -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
|
+
...
|
sqlym/mapper/pydantic.py
ADDED
|
@@ -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]
|
sqlym/parser/__init__.py
ADDED
|
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()
|