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 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
@@ -0,0 +1,8 @@
1
+ from .common import (
2
+ normalize_polars_df,
3
+ write_chunked_parquet,
4
+ write_seq_parquet
5
+ )
6
+ from .quote import get_step_quote_fields
7
+ from .snapshot import get_snapshot_l2_fields
8
+ from .trade import get_step_trade_pair_fields, get_step_trade_side_fields
@@ -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)
@@ -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
+ ]
@@ -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
+ """