yjdata 0.0.1__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.
- yjdata/__init__.py +0 -0
- yjdata/_optional.py +19 -0
- yjdata/dataset/__init__.py +8 -0
- yjdata/dataset/common.py +245 -0
- yjdata/dataset/quote.py +30 -0
- yjdata/dataset/snapshot.py +51 -0
- yjdata/dataset/trade.py +45 -0
- yjdata/db/__init__.py +15 -0
- yjdata/db/adapter.py +250 -0
- yjdata/db/expression.py +336 -0
- yjdata/db/mysql.py +92 -0
- yjdata/db/postgres.py +84 -0
- yjdata/db/sql.py +224 -0
- yjdata/dtype.py +16 -0
- yjdata-0.0.1.dist-info/METADATA +8 -0
- yjdata-0.0.1.dist-info/RECORD +18 -0
- yjdata-0.0.1.dist-info/WHEEL +5 -0
- yjdata-0.0.1.dist-info/top_level.txt +1 -0
yjdata/__init__.py
ADDED
|
File without changes
|
yjdata/_optional.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from importlib import import_module
|
|
2
|
+
from types import ModuleType
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def import_optional_dependency(
|
|
6
|
+
name: str, *, install: str | None = None,
|
|
7
|
+
) -> ModuleType:
|
|
8
|
+
"""Import a dependency with an optional installation requirement hint."""
|
|
9
|
+
root = name.split(".", 1)[0]
|
|
10
|
+
try:
|
|
11
|
+
return import_module(name)
|
|
12
|
+
except ModuleNotFoundError as exc:
|
|
13
|
+
if exc.name != root:
|
|
14
|
+
raise
|
|
15
|
+
message = (
|
|
16
|
+
f"Missing optional dependency '{root}'. "
|
|
17
|
+
f'Install it with: pip install "{install or root}"'
|
|
18
|
+
)
|
|
19
|
+
raise ImportError(message) from exc
|
yjdata/dataset/common.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import enum
|
|
3
|
+
import json
|
|
4
|
+
import polars as pl
|
|
5
|
+
import pyarrow as pa
|
|
6
|
+
import pyarrow.parquet as pq
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..dtype import DType
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
'DataField',
|
|
16
|
+
'DataScaleType',
|
|
17
|
+
|
|
18
|
+
'normalize_polars_df',
|
|
19
|
+
'write_chunked_parquet',
|
|
20
|
+
'write_seq_parquet'
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_PYARROW_DTYPE_MAP: dict[DType, pa.DataType] = {
|
|
25
|
+
DType.INT8: pa.int8(),
|
|
26
|
+
DType.UINT8: pa.uint8(),
|
|
27
|
+
DType.INT32: pa.int32(),
|
|
28
|
+
DType.INT64: pa.int64(),
|
|
29
|
+
DType.FLOAT64: pa.float64(),
|
|
30
|
+
DType.STRING: pa.string()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_POLARS_DTYPE_MAP: dict[DType, Any] = {
|
|
34
|
+
DType.INT8: pl.Int8,
|
|
35
|
+
DType.UINT8: pl.UInt8,
|
|
36
|
+
DType.INT32: pl.Int32,
|
|
37
|
+
DType.INT64: pl.Int64,
|
|
38
|
+
DType.FLOAT64: pl.Float64,
|
|
39
|
+
DType.STRING: pl.String,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DataScaleType(enum.IntEnum):
|
|
44
|
+
"""Scaling factors for data fields."""
|
|
45
|
+
NONE = 0
|
|
46
|
+
PRICE = 1 # scale by 1e6
|
|
47
|
+
AMOUNT = 2 # scale by 1e3
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclasses.dataclass
|
|
51
|
+
class DataField:
|
|
52
|
+
"""Define a data column and its conversion rules.
|
|
53
|
+
|
|
54
|
+
Parameters
|
|
55
|
+
----------
|
|
56
|
+
name : str
|
|
57
|
+
Column name.
|
|
58
|
+
dtype : DType
|
|
59
|
+
Target data type.
|
|
60
|
+
allow_empty : bool, default False
|
|
61
|
+
Whether missing or null values are allowed.
|
|
62
|
+
default_value : object, optional
|
|
63
|
+
Value used for missing columns and null entries.
|
|
64
|
+
scale_type : DataScaleType, default DataScaleType.NONE
|
|
65
|
+
Scaling rule applied when scale conversion is enabled.
|
|
66
|
+
"""
|
|
67
|
+
name: str
|
|
68
|
+
dtype: DType
|
|
69
|
+
allow_empty: bool = dataclasses.field(default=False)
|
|
70
|
+
default_value: Any = dataclasses.field(default=None)
|
|
71
|
+
scale_type: DataScaleType = dataclasses.field(default=DataScaleType.NONE)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def is_int_type(self):
|
|
75
|
+
return self.dtype in (
|
|
76
|
+
DType.INT8,
|
|
77
|
+
DType.UINT8,
|
|
78
|
+
DType.INT32,
|
|
79
|
+
DType.INT64,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def polars_dtype(self):
|
|
84
|
+
try:
|
|
85
|
+
return _POLARS_DTYPE_MAP[self.dtype]
|
|
86
|
+
except KeyError as error:
|
|
87
|
+
raise ValueError(f"Unsupported dtype '{self.dtype}' for polars conversion.") from error
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def pyarrow_dtype(self):
|
|
91
|
+
try:
|
|
92
|
+
return _PYARROW_DTYPE_MAP[self.dtype]
|
|
93
|
+
except KeyError as error:
|
|
94
|
+
raise ValueError(f"Unsupported dtype '{self.dtype}' for pyarrow conversion.") from error
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def normalize_polars_df(
|
|
98
|
+
data: pl.DataFrame,
|
|
99
|
+
fields: list[DataField],
|
|
100
|
+
convert_scale: bool = False
|
|
101
|
+
) -> pl.DataFrame:
|
|
102
|
+
"""Normalize column order, types, and optional scaling.
|
|
103
|
+
|
|
104
|
+
Parameters
|
|
105
|
+
----------
|
|
106
|
+
data : polars.DataFrame
|
|
107
|
+
Input rows.
|
|
108
|
+
fields : list of DataField
|
|
109
|
+
Target columns in output order. Unlisted columns are omitted.
|
|
110
|
+
convert_scale : bool, default False
|
|
111
|
+
Multiply prices by 1e6 and amounts by 1e3 according to scale_type.
|
|
112
|
+
|
|
113
|
+
Returns
|
|
114
|
+
-------
|
|
115
|
+
polars.DataFrame
|
|
116
|
+
Selected columns with defaults, scaling, and type conversions applied.
|
|
117
|
+
Floating-point values are rounded before conversion to integer types.
|
|
118
|
+
|
|
119
|
+
Raises
|
|
120
|
+
------
|
|
121
|
+
ValueError
|
|
122
|
+
If a required column is missing, contains nulls without a default,
|
|
123
|
+
or has an unsupported target type.
|
|
124
|
+
"""
|
|
125
|
+
expressions = []
|
|
126
|
+
column_names = set(data.columns)
|
|
127
|
+
|
|
128
|
+
# Track null checks to report the offending column.
|
|
129
|
+
null_filter_map = {}
|
|
130
|
+
|
|
131
|
+
for field in fields:
|
|
132
|
+
name = field.name
|
|
133
|
+
polars_dtype = field.polars_dtype
|
|
134
|
+
expr = pl.col(name)
|
|
135
|
+
|
|
136
|
+
if name not in column_names:
|
|
137
|
+
if not field.allow_empty and field.default_value is None:
|
|
138
|
+
raise ValueError(f"Missing required column '{name}' which does not allow empty.")
|
|
139
|
+
expr = pl.lit(field.default_value, dtype=polars_dtype).alias(name)
|
|
140
|
+
|
|
141
|
+
column_dtype = data.schema.get(name, polars_dtype)
|
|
142
|
+
if not field.allow_empty and field.default_value is None:
|
|
143
|
+
null_filter_map[name] = pl.col(name).is_null()
|
|
144
|
+
|
|
145
|
+
if field.default_value is not None:
|
|
146
|
+
expr = expr.fill_null(field.default_value)
|
|
147
|
+
|
|
148
|
+
if convert_scale:
|
|
149
|
+
if field.scale_type == DataScaleType.PRICE:
|
|
150
|
+
expr = expr * 1_000_000
|
|
151
|
+
elif field.scale_type == DataScaleType.AMOUNT:
|
|
152
|
+
expr = expr * 1_000
|
|
153
|
+
|
|
154
|
+
if column_dtype in (pl.Float32, pl.Float64) and field.is_int_type:
|
|
155
|
+
expr = expr.round(0).cast(polars_dtype, strict=False)
|
|
156
|
+
|
|
157
|
+
expressions.append(expr.cast(polars_dtype).alias(name))
|
|
158
|
+
|
|
159
|
+
# Check required columns for null values.
|
|
160
|
+
null_filter = pl.lit(False)
|
|
161
|
+
for f in null_filter_map.values():
|
|
162
|
+
null_filter |= f
|
|
163
|
+
|
|
164
|
+
if data.filter(null_filter).height:
|
|
165
|
+
# Identify the column containing null values.
|
|
166
|
+
for name, f in null_filter_map.items():
|
|
167
|
+
if data.filter(f).height:
|
|
168
|
+
raise ValueError(f'Column "{name}" contains null value, but it is marked as not allowed.')
|
|
169
|
+
|
|
170
|
+
return data.select(expressions)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _normalize_string_type(tb: pa.Table) -> pa.Table:
|
|
174
|
+
columns = []
|
|
175
|
+
for col in tb.schema:
|
|
176
|
+
if pa.types.is_large_string(col.type):
|
|
177
|
+
t = pa.string()
|
|
178
|
+
else:
|
|
179
|
+
t = col.type
|
|
180
|
+
columns.append(pa.field(col.name, t))
|
|
181
|
+
schema = pa.schema(columns)
|
|
182
|
+
return tb.cast(schema)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def write_chunked_parquet(
|
|
186
|
+
df: pl.DataFrame,
|
|
187
|
+
path: str | Path,
|
|
188
|
+
chunked_by: str = 'Symbol',
|
|
189
|
+
sort_chunks: bool = True,
|
|
190
|
+
metadata_key: bytes = b'group_symbols'
|
|
191
|
+
):
|
|
192
|
+
"""Write grouped rows to a single Parquet file.
|
|
193
|
+
|
|
194
|
+
Parameters
|
|
195
|
+
----------
|
|
196
|
+
df : polars.DataFrame
|
|
197
|
+
Rows to write.
|
|
198
|
+
path : str or pathlib.Path
|
|
199
|
+
Output file path.
|
|
200
|
+
chunked_by : str, default 'Symbol'
|
|
201
|
+
Column used to group rows.
|
|
202
|
+
sort_chunks : bool, default True
|
|
203
|
+
Sort groups by key. Otherwise, use the order returned by Polars.
|
|
204
|
+
metadata_key : bytes, default b'group_symbols'
|
|
205
|
+
Schema metadata key for a JSON mapping from stringified group keys
|
|
206
|
+
to zero-based group indices.
|
|
207
|
+
|
|
208
|
+
Notes
|
|
209
|
+
-----
|
|
210
|
+
Each group is written in a separate write call. Arrow large string
|
|
211
|
+
columns are converted to string columns before writing.
|
|
212
|
+
"""
|
|
213
|
+
groups = list(df.group_by(chunked_by))
|
|
214
|
+
if sort_chunks:
|
|
215
|
+
groups.sort(key=lambda g: g[0])
|
|
216
|
+
|
|
217
|
+
base_table = _normalize_string_type(df.head(0).to_arrow())
|
|
218
|
+
metadata = dict(base_table.schema.metadata or {})
|
|
219
|
+
metadata[metadata_key] = ','.join(str(key) for key, _ in groups).encode('utf-8')
|
|
220
|
+
|
|
221
|
+
with pq.ParquetWriter(path, base_table.schema.with_metadata(metadata)) as writer:
|
|
222
|
+
for _, group in groups:
|
|
223
|
+
table = _normalize_string_type(group.to_arrow())
|
|
224
|
+
writer.write_table(table)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def write_seq_parquet(
|
|
228
|
+
df: pl.DataFrame,
|
|
229
|
+
path: str | Path,
|
|
230
|
+
):
|
|
231
|
+
"""Write rows to a Parquet file in their existing order.
|
|
232
|
+
|
|
233
|
+
Parameters
|
|
234
|
+
----------
|
|
235
|
+
df : polars.DataFrame
|
|
236
|
+
Rows to write without additional grouping or sorting.
|
|
237
|
+
path : str or pathlib.Path
|
|
238
|
+
Output file path.
|
|
239
|
+
|
|
240
|
+
Notes
|
|
241
|
+
-----
|
|
242
|
+
Arrow large string columns are converted to string columns before writing.
|
|
243
|
+
"""
|
|
244
|
+
table = _normalize_string_type(df.to_arrow())
|
|
245
|
+
pq.write_table(table, path)
|
yjdata/dataset/quote.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
|
|
3
|
+
from .common import DataField, DataScaleType
|
|
4
|
+
from ..dtype import DType
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
'get_step_quote_fields',
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_step_quote_fields() -> List[DataField]:
|
|
13
|
+
return [
|
|
14
|
+
DataField('Symbol', DType.STRING),
|
|
15
|
+
DataField('Exchange', DType.STRING),
|
|
16
|
+
DataField('TradeDate', DType.INT32),
|
|
17
|
+
DataField('Time', DType.INT64),
|
|
18
|
+
DataField('RecvTime', DType.INT64, True),
|
|
19
|
+
DataField('Price', DType.INT64, scale_type=DataScaleType.PRICE),
|
|
20
|
+
DataField('Qty', DType.INT64),
|
|
21
|
+
DataField('ModType', DType.UINT8),
|
|
22
|
+
DataField('PriceType', DType.UINT8, False, 0),
|
|
23
|
+
DataField('OrderType', DType.UINT8, False, 0),
|
|
24
|
+
DataField('Side', DType.UINT8),
|
|
25
|
+
DataField('PositionOffset', DType.UINT8, True),
|
|
26
|
+
DataField('ChannelId', DType.INT64, False, 0),
|
|
27
|
+
DataField('OrderNo', DType.INT64, False, 0),
|
|
28
|
+
DataField('RecNo', DType.INT64, False, 0),
|
|
29
|
+
DataField('MessageNo', DType.INT64, False, 0),
|
|
30
|
+
]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
|
|
3
|
+
from .common import DataField, DataScaleType
|
|
4
|
+
from ..dtype import DType
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
'get_snapshot_l2_fields'
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_snapshot_l2_fields() -> List[DataField]:
|
|
13
|
+
required_level_fields = []
|
|
14
|
+
for prefix in ['Bid', 'BidQty', 'Ask', 'AskQty']:
|
|
15
|
+
for i in range(1, 11):
|
|
16
|
+
if prefix.endswith('Qty'):
|
|
17
|
+
required_level_fields.append(DataField(f'{prefix}{i}', DType.INT64, True, 0))
|
|
18
|
+
else:
|
|
19
|
+
required_level_fields.append(DataField(f'{prefix}{i}', DType.INT64, True, scale_type=DataScaleType.PRICE))
|
|
20
|
+
|
|
21
|
+
return [
|
|
22
|
+
DataField('Symbol', DType.STRING),
|
|
23
|
+
DataField('Exchange', DType.STRING),
|
|
24
|
+
DataField('TradeDate', DType.INT32),
|
|
25
|
+
DataField('Time', DType.INT64),
|
|
26
|
+
DataField('RecvTime', DType.INT64, True),
|
|
27
|
+
DataField('Phase', DType.UINT8, True),
|
|
28
|
+
DataField('PreClose', DType.INT64, True, scale_type=DataScaleType.PRICE),
|
|
29
|
+
DataField('PreSettle', DType.INT64, True, scale_type=DataScaleType.PRICE),
|
|
30
|
+
DataField('PreOpenInterest', DType.INT64, True),
|
|
31
|
+
DataField('Open', DType.INT64, True, scale_type=DataScaleType.PRICE),
|
|
32
|
+
DataField('High', DType.INT64, True, scale_type=DataScaleType.PRICE),
|
|
33
|
+
DataField('Low', DType.INT64, True, scale_type=DataScaleType.PRICE),
|
|
34
|
+
DataField('Last', DType.INT64, scale_type=DataScaleType.PRICE),
|
|
35
|
+
DataField('Qty', DType.INT64),
|
|
36
|
+
DataField('Amount', DType.INT64, scale_type=DataScaleType.AMOUNT),
|
|
37
|
+
*required_level_fields,
|
|
38
|
+
DataField('UpperLimit', DType.INT64, True, scale_type=DataScaleType.PRICE),
|
|
39
|
+
DataField('LowerLimit', DType.INT64, True, scale_type=DataScaleType.PRICE),
|
|
40
|
+
DataField('OpenInterest', DType.INT64, True),
|
|
41
|
+
DataField('TradeCount', DType.INT64, True),
|
|
42
|
+
DataField('BidQty', DType.INT64, True),
|
|
43
|
+
DataField('AskQty', DType.INT64, True),
|
|
44
|
+
DataField('BidCount', DType.INT64, True),
|
|
45
|
+
DataField('AskCount', DType.INT64, True),
|
|
46
|
+
DataField('BidAvg', DType.INT64, True, scale_type=DataScaleType.PRICE),
|
|
47
|
+
DataField('AskAvg', DType.INT64, True, scale_type=DataScaleType.PRICE),
|
|
48
|
+
DataField('Close', DType.INT64, True, scale_type=DataScaleType.PRICE),
|
|
49
|
+
DataField('Settle', DType.INT64, True, scale_type=DataScaleType.PRICE),
|
|
50
|
+
DataField('MessageNo', DType.INT64, False, 0),
|
|
51
|
+
]
|
yjdata/dataset/trade.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
|
|
3
|
+
from .common import DataField, DataScaleType
|
|
4
|
+
from ..dtype import DType
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
'get_step_trade_side_fields',
|
|
9
|
+
'get_step_trade_pair_fields'
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_step_trade_side_fields() -> List[DataField]:
|
|
14
|
+
return [
|
|
15
|
+
DataField('Symbol', DType.STRING),
|
|
16
|
+
DataField('Exchange', DType.STRING),
|
|
17
|
+
DataField('TradeDate', DType.INT32),
|
|
18
|
+
DataField('Time', DType.INT64),
|
|
19
|
+
DataField('RecvTime', DType.INT64, True),
|
|
20
|
+
DataField('Price', DType.INT64, scale_type=DataScaleType.PRICE),
|
|
21
|
+
DataField('Qty', DType.INT64),
|
|
22
|
+
DataField('OrderId', DType.INT64, False, 0),
|
|
23
|
+
DataField('Side', DType.UINT8),
|
|
24
|
+
DataField('PairNo', DType.INT64, False, 0),
|
|
25
|
+
DataField('ChannelId', DType.INT64, False, 0),
|
|
26
|
+
DataField('RecNo', DType.INT64, False, 0),
|
|
27
|
+
DataField('MessageNo', DType.INT64, False, 0),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_step_trade_pair_fields() -> List[DataField]:
|
|
32
|
+
return [
|
|
33
|
+
DataField('Symbol', DType.STRING),
|
|
34
|
+
DataField('Exchange', DType.STRING),
|
|
35
|
+
DataField('TradeDate', DType.INT32),
|
|
36
|
+
DataField('Time', DType.INT64),
|
|
37
|
+
DataField('RecvTime', DType.INT64, True),
|
|
38
|
+
DataField('Price', DType.INT64, scale_type=DataScaleType.PRICE),
|
|
39
|
+
DataField('Qty', DType.INT64),
|
|
40
|
+
DataField('BidOrderId', DType.INT64, False, 0),
|
|
41
|
+
DataField('AskOrderId', DType.INT64, False, 0),
|
|
42
|
+
DataField('ChannelId', DType.INT64, False, 0),
|
|
43
|
+
DataField('RecNo', DType.INT64, False, 0),
|
|
44
|
+
DataField('MessageNo', DType.INT64, False, 0),
|
|
45
|
+
]
|
yjdata/db/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .adapter import DbAdapter
|
|
2
|
+
from .expression import Expression, col, lit
|
|
3
|
+
from .sql import SqlAdapter
|
|
4
|
+
from .mysql import MysqlAdapter
|
|
5
|
+
from .postgres import PostgresAdapter
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"DbAdapter",
|
|
9
|
+
"SqlAdapter",
|
|
10
|
+
"Expression",
|
|
11
|
+
"col",
|
|
12
|
+
"lit",
|
|
13
|
+
"PostgresAdapter",
|
|
14
|
+
"MysqlAdapter"
|
|
15
|
+
]
|
yjdata/db/adapter.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import polars as pl
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any, Sequence
|
|
5
|
+
|
|
6
|
+
from .expression import Expression
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def validate_batch_size(batch_size: int) -> None:
|
|
10
|
+
"""Require a positive integer batch size, excluding booleans."""
|
|
11
|
+
if isinstance(batch_size, bool) or not isinstance(batch_size, int) or batch_size <= 0:
|
|
12
|
+
raise ValueError("batch_size must be a positive integer")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate_insert_args(df: pl.DataFrame, batch_size: int) -> None:
|
|
16
|
+
"""Validate batch size and require at least one DataFrame column."""
|
|
17
|
+
validate_batch_size(batch_size)
|
|
18
|
+
if not df.columns:
|
|
19
|
+
raise ValueError("Cannot insert a DataFrame with no columns")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def validate_delete_args(filter: Expression | None) -> None:
|
|
23
|
+
"""Accept a deletion expression or None."""
|
|
24
|
+
if filter is not None and not isinstance(filter, Expression):
|
|
25
|
+
raise TypeError("filter must be an Expression or None")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def validate_select_args(filter: Expression | None, columns: Sequence[str] | None) -> None:
|
|
29
|
+
"""Require an optional expression and a nonempty sequence of column names."""
|
|
30
|
+
if filter is not None and not isinstance(filter, Expression):
|
|
31
|
+
raise TypeError("filter must be an Expression or None")
|
|
32
|
+
if columns is not None:
|
|
33
|
+
if isinstance(columns, (str, bytes)) or not isinstance(columns, Sequence):
|
|
34
|
+
raise TypeError("columns must be a sequence of column names")
|
|
35
|
+
if not columns:
|
|
36
|
+
raise ValueError("columns must contain at least one column")
|
|
37
|
+
if any(not isinstance(column, str) for column in columns):
|
|
38
|
+
raise TypeError("Column names must be strings")
|
|
39
|
+
if any(not column or "\x00" in column for column in columns):
|
|
40
|
+
raise ValueError("Column names must be nonempty and contain no NUL characters")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validate_update_args(
|
|
44
|
+
keys: Sequence[str],
|
|
45
|
+
df: pl.DataFrame,
|
|
46
|
+
batch_size: int,
|
|
47
|
+
*,
|
|
48
|
+
require_update_columns: bool = True,
|
|
49
|
+
) -> list[str]:
|
|
50
|
+
"""Validate update/upsert arguments and return non-key columns in input order."""
|
|
51
|
+
validate_batch_size(batch_size)
|
|
52
|
+
if not keys:
|
|
53
|
+
raise ValueError("keys must contain at least one column")
|
|
54
|
+
|
|
55
|
+
missing_keys = [key for key in keys if key not in df.columns]
|
|
56
|
+
if missing_keys:
|
|
57
|
+
raise ValueError(f"Key columns are missing from the DataFrame: {missing_keys}")
|
|
58
|
+
|
|
59
|
+
update_columns = [column for column in df.columns if column not in keys]
|
|
60
|
+
if require_update_columns and not update_columns:
|
|
61
|
+
raise ValueError("The DataFrame must contain at least one non-key column")
|
|
62
|
+
return update_columns
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class DbAdapter(ABC):
|
|
66
|
+
"""Interface for exchanging tabular data between databases and Python objects."""
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
def delete_data(self, table: str, filter: Expression | None = None) -> None:
|
|
70
|
+
"""Delete rows matching an expression.
|
|
71
|
+
|
|
72
|
+
Parameters
|
|
73
|
+
----------
|
|
74
|
+
table : str
|
|
75
|
+
Target table name, optionally qualified by schema or database.
|
|
76
|
+
filter : Expression or None, default None
|
|
77
|
+
Condition identifying rows to delete. None deletes all rows.
|
|
78
|
+
|
|
79
|
+
Raises
|
|
80
|
+
------
|
|
81
|
+
TypeError
|
|
82
|
+
If filter is neither an Expression nor None.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def insert_data(self, table: str, data: list[dict[str, Any]], batch_size: int = 65536) -> None:
|
|
86
|
+
"""Insert dictionary rows in batches within one transaction.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
table : str
|
|
91
|
+
Target table name, optionally qualified by schema or database.
|
|
92
|
+
data : list of dict
|
|
93
|
+
Rows to insert. An empty list returns without validation.
|
|
94
|
+
batch_size : int, default 65536
|
|
95
|
+
Maximum rows per batch. Must be a positive integer.
|
|
96
|
+
|
|
97
|
+
Raises
|
|
98
|
+
------
|
|
99
|
+
ValueError
|
|
100
|
+
If nonempty data has no columns or batch_size is invalid.
|
|
101
|
+
"""
|
|
102
|
+
if not data:
|
|
103
|
+
return
|
|
104
|
+
df = pl.DataFrame(data)
|
|
105
|
+
self.insert_polars(table, df, batch_size)
|
|
106
|
+
|
|
107
|
+
@abstractmethod
|
|
108
|
+
def insert_polars(
|
|
109
|
+
self,
|
|
110
|
+
table: str,
|
|
111
|
+
df: pl.DataFrame,
|
|
112
|
+
batch_size: int = 65536,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Insert DataFrame rows in batches within one transaction.
|
|
115
|
+
|
|
116
|
+
Parameters
|
|
117
|
+
----------
|
|
118
|
+
table : str
|
|
119
|
+
Target table name, optionally qualified by schema or database.
|
|
120
|
+
df : polars.DataFrame
|
|
121
|
+
Rows to insert. A frame with columns but no rows is a no-op.
|
|
122
|
+
batch_size : int, default 65536
|
|
123
|
+
Maximum rows per batch. Must be a positive integer.
|
|
124
|
+
|
|
125
|
+
Raises
|
|
126
|
+
------
|
|
127
|
+
ValueError
|
|
128
|
+
If batch_size is invalid or df has no columns.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def select_data(
|
|
132
|
+
self,
|
|
133
|
+
table: str,
|
|
134
|
+
*,
|
|
135
|
+
filter: Expression | None = None,
|
|
136
|
+
columns: Sequence[str] | None = None,
|
|
137
|
+
) -> list[dict[str, Any]]:
|
|
138
|
+
"""Select rows from one table as dictionaries.
|
|
139
|
+
|
|
140
|
+
Parameters
|
|
141
|
+
----------
|
|
142
|
+
table : str
|
|
143
|
+
Target table name, optionally qualified by schema or database.
|
|
144
|
+
filter : Expression, optional
|
|
145
|
+
Condition to match; None selects all rows.
|
|
146
|
+
columns : sequence of str, optional
|
|
147
|
+
Nonempty sequence of column names; None selects all columns.
|
|
148
|
+
|
|
149
|
+
Returns
|
|
150
|
+
-------
|
|
151
|
+
list of dict
|
|
152
|
+
Matching rows keyed by column name, without a guaranteed order.
|
|
153
|
+
"""
|
|
154
|
+
raise NotImplementedError("This adapter does not implement selection")
|
|
155
|
+
|
|
156
|
+
def select_polars(
|
|
157
|
+
self,
|
|
158
|
+
table: str,
|
|
159
|
+
*,
|
|
160
|
+
filter: Expression | None = None,
|
|
161
|
+
columns: Sequence[str] | None = None,
|
|
162
|
+
) -> pl.DataFrame:
|
|
163
|
+
"""Select rows from one table as a Polars DataFrame.
|
|
164
|
+
|
|
165
|
+
Parameters
|
|
166
|
+
----------
|
|
167
|
+
table : str
|
|
168
|
+
Target table name, optionally qualified by schema or database.
|
|
169
|
+
filter : Expression, optional
|
|
170
|
+
Condition to match; None selects all rows.
|
|
171
|
+
columns : sequence of str, optional
|
|
172
|
+
Nonempty sequence of column names; None selects all columns.
|
|
173
|
+
|
|
174
|
+
Returns
|
|
175
|
+
-------
|
|
176
|
+
polars.DataFrame
|
|
177
|
+
Matching rows with types inferred from all rows, without a
|
|
178
|
+
guaranteed order. An empty result has no columns.
|
|
179
|
+
"""
|
|
180
|
+
raise NotImplementedError("This adapter does not implement selection")
|
|
181
|
+
|
|
182
|
+
@abstractmethod
|
|
183
|
+
def update_polars(
|
|
184
|
+
self,
|
|
185
|
+
table: str,
|
|
186
|
+
keys: Sequence[str],
|
|
187
|
+
df: pl.DataFrame,
|
|
188
|
+
batch_size: int = 65536,
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Update non-key columns in rows matching all key columns.
|
|
191
|
+
|
|
192
|
+
Parameters
|
|
193
|
+
----------
|
|
194
|
+
table : str
|
|
195
|
+
Target table name, optionally qualified by schema or database.
|
|
196
|
+
keys : sequence of str
|
|
197
|
+
Nonempty list of key columns present in df.
|
|
198
|
+
df : polars.DataFrame
|
|
199
|
+
Rows to write. An empty frame is a no-op after validation.
|
|
200
|
+
batch_size : int, default 65536
|
|
201
|
+
Maximum rows per batch. Must be a positive integer.
|
|
202
|
+
|
|
203
|
+
Raises
|
|
204
|
+
------
|
|
205
|
+
ValueError
|
|
206
|
+
If batch_size is invalid, keys is empty, a key is missing from df,
|
|
207
|
+
or df has no non-key columns.
|
|
208
|
+
|
|
209
|
+
Notes
|
|
210
|
+
-----
|
|
211
|
+
All batches execute within one transaction.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
@abstractmethod
|
|
215
|
+
def upsert_polars(
|
|
216
|
+
self,
|
|
217
|
+
table: str,
|
|
218
|
+
keys: Sequence[str],
|
|
219
|
+
df: pl.DataFrame,
|
|
220
|
+
batch_size: int = 65536,
|
|
221
|
+
) -> None:
|
|
222
|
+
"""Insert rows, updating non-key columns on conflicts.
|
|
223
|
+
|
|
224
|
+
Parameters
|
|
225
|
+
----------
|
|
226
|
+
table : str
|
|
227
|
+
Target table name, optionally qualified by schema or database.
|
|
228
|
+
keys : sequence of str
|
|
229
|
+
Nonempty list of key columns present in df.
|
|
230
|
+
df : polars.DataFrame
|
|
231
|
+
Rows to write. An empty frame is a no-op after validation.
|
|
232
|
+
batch_size : int, default 65536
|
|
233
|
+
Maximum rows per batch. Must be a positive integer.
|
|
234
|
+
|
|
235
|
+
Raises
|
|
236
|
+
------
|
|
237
|
+
ValueError
|
|
238
|
+
If batch_size is invalid, keys is empty, or a key is missing from df.
|
|
239
|
+
|
|
240
|
+
Notes
|
|
241
|
+
-----
|
|
242
|
+
All batches execute within one transaction.
|
|
243
|
+
|
|
244
|
+
PostgreSQL requires keys to match a primary key or unique constraint
|
|
245
|
+
usable with ON CONFLICT. MySQL updates on any primary key or unique
|
|
246
|
+
constraint conflict, regardless of keys.
|
|
247
|
+
|
|
248
|
+
If df contains only key columns, PostgreSQL skips conflicts with
|
|
249
|
+
DO NOTHING; MySQL assigns a key column to itself to preserve values.
|
|
250
|
+
"""
|