Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/integration-tests-mssql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
sudo dpkg -i packages-microsoft-prod.deb
rm packages-microsoft-prod.deb
sudo apt-get update
sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18
sudo ACCEPT_EULA=Y apt-get install -y msodbcsql18 mssql-tools18
python -m pip install --upgrade pip
python -m pip install flake8 .[tests] pyodbc

Expand All @@ -44,3 +44,4 @@ jobs:
pytest tests -x
env:
DB_STRING: mssql+pyodbc://sa:MyTestPassword1@localhost:1433/master?driver=ODBC+Driver+18+for+SQL+Server&TrustServerCertificate=yes
PATH: /opt/mssql-tools18/bin:${{ env.PATH }}
7 changes: 5 additions & 2 deletions src/xml2db/dialect/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,20 @@
}


def get_dialect(db_type: str | None) -> DatabaseDialect:
def get_dialect(db_type: str | None, **kwargs) -> DatabaseDialect:
"""Return a :class:`DatabaseDialect` instance for the given backend name.

Args:
db_type: The SQLAlchemy dialect name, e.g. ``"postgresql"``,
``"mssql"``, ``"mysql"``, ``"duckdb"``. ``None`` or any
unrecognised string falls back to the base
:class:`DatabaseDialect`, which uses safe generic defaults.
**kwargs: Extra keyword arguments forwarded to the dialect constructor.
Unknown kwargs are silently ignored by subclasses that do not
declare them.

Returns:
An instantiated :class:`DatabaseDialect` (or subclass) ready for use.
"""
cls = DIALECT_REGISTRY.get(db_type, DatabaseDialect)
return cls()
return cls(**kwargs)
3 changes: 3 additions & 0 deletions src/xml2db/dialect/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ class DatabaseDialect:

MAX_IDENTIFIER_LENGTH: int = 63 # conservative default; matches PostgreSQL

def __init__(self, **kwargs):
pass

# ------------------------------------------------------------------
# Identifier handling
# ------------------------------------------------------------------
Expand Down
119 changes: 119 additions & 0 deletions src/xml2db/dialect/mssql.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import os
import shutil
import subprocess
import tempfile
from typing import Any, List, TYPE_CHECKING

from sqlalchemy import Index
Expand All @@ -9,16 +13,31 @@
from ..table.column import DataModelColumn


# Records below this count go through fast_executemany; at or above, BCP is used.
_BCP_THRESHOLD = 1000


class MSSQLDialect(DatabaseDialect):
"""Dialect for Microsoft SQL Server.

MSSQL supports identifiers up to 128 characters, so no truncation is
needed. Columnstore index support and MSSQL-specific type mappings are
handled in this class.

When the ``bcp`` utility is available on PATH and the connection uses SQL
authentication, :meth:`bulk_insert` switches to BCP for batches of
:data:`_BCP_THRESHOLD` rows or more. Smaller batches always use
``fast_executemany`` (enabled at engine level) to avoid BCP's subprocess
overhead. Pass ``use_bcp=False`` to :class:`~xml2db.DataModel` to disable
BCP entirely.
"""

MAX_IDENTIFIER_LENGTH: int = 128

def __init__(self, use_bcp: bool = True, **kwargs):
super().__init__(**kwargs)
self.bcp_path = shutil.which("bcp") if use_bcp else None

def validate_table_config(self, config: dict) -> dict:
"""Allow ``as_columnstore`` through unchanged for MSSQL."""
return config
Expand Down Expand Up @@ -73,3 +92,103 @@ def relation_extra_indexes(
mssql_clustered=True,
),
)

@staticmethod
def _format_bcp_value(v: Any) -> str:
"""Format a Python value as a BCP character-mode field (tab-separated)."""
if v is None:
return ""
if isinstance(v, bool):
# Must precede int: bool is a subclass of int.
return "1" if v else "0"
if isinstance(v, (bytes, bytearray)):
# BCP character mode accepts hex without 0x prefix for binary columns.
return v.hex()
s = str(v)
# Tab is the field delimiter; replace any occurrence in string values.
return s.replace("\t", " ")

def bulk_insert(self, conn: Any, table: Any, records: list) -> None:
"""Bulk-insert records, using BCP for large batches when available.

Batches smaller than :data:`_BCP_THRESHOLD` rows, or any batch when
BCP is unavailable or the connection has neither SQL credentials nor
``Trusted_Connection=yes``, fall back to ``fast_executemany``.
SQL auth uses ``-U``/``-P``; Kerberos/Windows auth uses ``-T``.

BCP does not participate in the caller's SQLAlchemy transaction.

Args:
conn: A SQLAlchemy ``Connection`` already within a transaction.
table: The SQLAlchemy ``Table`` object to insert into.
records: A list of dicts mapping column keys to Python values.
"""
if not records:
return

url = conn.engine.url
trusted = str(url.query.get("Trusted_Connection", "")).lower() == "yes"
has_sql_auth = bool(url.username and url.password)
if (
self.bcp_path is None
or len(records) < _BCP_THRESHOLD
or (not has_sql_auth and not trusted)
):
super().bulk_insert(conn, table, records)
return

col_by_key = {col.key: col for col in table.columns}
col_keys = [k for k in records[0] if k in col_by_key]

# Python-side scalar defaults absent from records (same as other dialects).
extra_defaults: dict = {}
for col in table.columns:
if col.key not in records[0] and col.key in col_by_key:
d = col.default
if d is not None and d.is_scalar:
extra_defaults[col.key] = d.arg

all_col_keys = col_keys + list(extra_defaults.keys())

full_name = (
f"[{table.schema}].[{table.name}]"
if table.schema
else f"[{table.name}]"
)

fd, data_path = tempfile.mkstemp(suffix=".bcp")
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="") as f:
for record in records:
row = []
for key in all_col_keys:
v = record.get(key) if key in col_keys else extra_defaults[key]
row.append(self._format_bcp_value(v))
f.write("\t".join(row) + "\n")

cmd = [
self.bcp_path, full_name, "in", data_path,
"-S", f"{url.host},{url.port or 1433}",
"-d", url.database,
"-c", # character (text) mode
"-t", "\t", # tab field separator
"-r", "\n", # newline row terminator
"-k", # empty field → NULL (not column default)
"-b", "10000",
"-C", "65001", # UTF-8
]
if has_sql_auth:
cmd += ["-U", url.username, "-P", url.password]
else:
cmd.append("-T") # Kerberos / Windows trusted connection
if str(url.query.get("TrustServerCertificate", "")).lower() == "yes":
cmd.append("-u") # trust server certificate (mssql-tools18)
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(
f"BCP failed (exit {result.returncode}):\n"
f"{result.stdout}\n{result.stderr}"
)
finally:
if os.path.exists(data_path):
os.unlink(data_path)
3 changes: 2 additions & 1 deletion src/xml2db/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ def __init__(
db_type: str = None,
db_schema: str = None,
temp_prefix: str = None,
use_bcp: bool = True,
):
self.model_config = self._validate_config(model_config)
self.tables_config = model_config.get("tables", {}) if model_config else {}
Expand Down Expand Up @@ -118,7 +119,7 @@ def __init__(
)
self.db_type = self.engine.dialect.name

self.dialect = get_dialect(self.db_type)
self.dialect = get_dialect(self.db_type, use_bcp=use_bcp)
self.model_config = self.dialect.validate_model_config(self.model_config)
self.db_schema = db_schema
self.temp_prefix = str(uuid4())[:8] if temp_prefix is None else temp_prefix
Expand Down
Loading
Loading