csv-to-db-py 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.
- csv_to_db_py/__init__.py +4 -0
- csv_to_db_py/config.py +44 -0
- csv_to_db_py/core.py +90 -0
- csv_to_db_py/db_connectors/__init__.py +5 -0
- csv_to_db_py/db_connectors/base.py +14 -0
- csv_to_db_py/db_connectors/mssql.py +17 -0
- csv_to_db_py/db_connectors/mysql.py +16 -0
- csv_to_db_py/db_connectors/oracle.py +17 -0
- csv_to_db_py/db_connectors/postgres.py +17 -0
- csv_to_db_py/logger.py +19 -0
- csv_to_db_py/main.py +32 -0
- csv_to_db_py/utils/__init__.py +7 -0
- csv_to_db_py/utils/cleaned_csv.py +105 -0
- csv_to_db_py/utils/csv_loader.py +265 -0
- csv_to_db_py/utils/table_creator.py +19 -0
- csv_to_db_py-0.1.0.dist-info/METADATA +276 -0
- csv_to_db_py-0.1.0.dist-info/RECORD +20 -0
- csv_to_db_py-0.1.0.dist-info/WHEEL +4 -0
- csv_to_db_py-0.1.0.dist-info/entry_points.txt +2 -0
- csv_to_db_py-0.1.0.dist-info/licenses/LICENSE.md +22 -0
csv_to_db_py/__init__.py
ADDED
csv_to_db_py/config.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
SUPPORTED_DB_TYPES = {"postgres", "mysql", "mssql", "oracle"}
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def validate_config(cfg: dict) -> None:
|
|
7
|
+
db_config = cfg.get("DATABASE_CONFIG")
|
|
8
|
+
if not isinstance(db_config, dict):
|
|
9
|
+
raise ValueError("config 'DATABASE_CONFIG' must be a dict")
|
|
10
|
+
db_type = db_config.get("type")
|
|
11
|
+
if db_type not in SUPPORTED_DB_TYPES:
|
|
12
|
+
raise ValueError(
|
|
13
|
+
f"config 'DATABASE_CONFIG.type' must be one of {', '.join(sorted(SUPPORTED_DB_TYPES))}, got {db_type!r}"
|
|
14
|
+
)
|
|
15
|
+
if "files_dir" not in cfg:
|
|
16
|
+
raise ValueError("config is missing required key 'files_dir'")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_config(config_path: str) -> dict:
|
|
20
|
+
with open(config_path, "r") as f:
|
|
21
|
+
cfg = json.load(f)
|
|
22
|
+
validate_config(cfg)
|
|
23
|
+
return cfg
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
PG_RESERVED = {
|
|
27
|
+
"insert",
|
|
28
|
+
"delete",
|
|
29
|
+
"update",
|
|
30
|
+
"select",
|
|
31
|
+
"drop",
|
|
32
|
+
"and",
|
|
33
|
+
"or",
|
|
34
|
+
"from",
|
|
35
|
+
"create",
|
|
36
|
+
"join",
|
|
37
|
+
"limit",
|
|
38
|
+
"table",
|
|
39
|
+
"order",
|
|
40
|
+
"user",
|
|
41
|
+
"not",
|
|
42
|
+
"where",
|
|
43
|
+
"group",
|
|
44
|
+
}
|
csv_to_db_py/core.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
import polars as pl
|
|
5
|
+
|
|
6
|
+
from csv_to_db_py.db_connectors import postgres, mysql, mssql, oracle
|
|
7
|
+
from csv_to_db_py.db_connectors.base import BaseDBConnector
|
|
8
|
+
from csv_to_db_py.utils.table_creator import create_table_from_csv
|
|
9
|
+
from csv_to_db_py.utils.csv_loader import (
|
|
10
|
+
postgrestype_dict,
|
|
11
|
+
overwrite_table_with_csv_data,
|
|
12
|
+
convert_datetime_columns,
|
|
13
|
+
)
|
|
14
|
+
from csv_to_db_py.utils.cleaned_csv import apply_filter, get_dataframe_cleaned_new
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_connector(db_config: dict) -> BaseDBConnector:
|
|
18
|
+
db_type = db_config["type"]
|
|
19
|
+
if db_type == "postgres":
|
|
20
|
+
return postgres.PostgresConnector(db_config)
|
|
21
|
+
elif db_type == "mysql":
|
|
22
|
+
return mysql.MySQLConnector(db_config)
|
|
23
|
+
elif db_type == "mssql":
|
|
24
|
+
return mssql.MSSQLConnector(db_config)
|
|
25
|
+
elif db_type == "oracle":
|
|
26
|
+
return oracle.OracleConnector(db_config)
|
|
27
|
+
else:
|
|
28
|
+
raise ValueError("Base de données non supportée")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_csv_to_db(config: dict, input_path: str | None = None) -> None:
|
|
32
|
+
if input_path is None:
|
|
33
|
+
input_path = config["files_dir"]
|
|
34
|
+
|
|
35
|
+
connector = get_connector(config["DATABASE_CONFIG"])
|
|
36
|
+
connector.connect()
|
|
37
|
+
engine = connector.get_engine()
|
|
38
|
+
|
|
39
|
+
# Détection du(s) fichier(s) CSV ou XLSX
|
|
40
|
+
if os.path.isfile(input_path) and (
|
|
41
|
+
input_path.endswith(".csv") or input_path.endswith(".xlsx")
|
|
42
|
+
):
|
|
43
|
+
files = [input_path]
|
|
44
|
+
|
|
45
|
+
elif os.path.isdir(input_path):
|
|
46
|
+
files = [
|
|
47
|
+
os.path.join(input_path, f)
|
|
48
|
+
for f in os.listdir(input_path)
|
|
49
|
+
if f.endswith(".csv") or f.endswith(".xlsx")
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
else:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
"Le chemin fourni n'est ni un fichier CSV/XLSX existant ni un dossier valide"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
print(f"Fichiers trouvés : {files}")
|
|
58
|
+
isUniqueFile = len(files) == 1
|
|
59
|
+
for file_path in files:
|
|
60
|
+
print(f"Traitement du fichier : {file_path}")
|
|
61
|
+
df = get_dataframe_cleaned_new(file_path, delimiter=config.get("delimiter"))
|
|
62
|
+
|
|
63
|
+
# Appliquer le filtre sur la colonne pour la valeur souhaitée
|
|
64
|
+
filter_config = config.get("FILTER", {})
|
|
65
|
+
df = apply_filter(df, filter_config)
|
|
66
|
+
if filter_config.get("column") and filter_config.get("value") is not None:
|
|
67
|
+
logging.info(
|
|
68
|
+
f"Filtre appliqué : {filter_config.get('column')} = {filter_config.get('value')} ({len(df)} lignes restantes)"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
print(df.columns)
|
|
72
|
+
# ponytail: strptime runs on the whole file; polars is fast, re-sample only if it proves too slow
|
|
73
|
+
df = convert_datetime_columns(
|
|
74
|
+
df, threshold=config.get("datetime_detection_threshold", 1.0)
|
|
75
|
+
)
|
|
76
|
+
types_dict = postgrestype_dict(df)
|
|
77
|
+
print(types_dict)
|
|
78
|
+
|
|
79
|
+
if isUniqueFile:
|
|
80
|
+
table_name = config["table"]
|
|
81
|
+
else:
|
|
82
|
+
table_name = os.path.splitext(os.path.basename(file_path))[0]
|
|
83
|
+
create_table_from_csv(engine, df, table_name, types_dict)
|
|
84
|
+
print(f"Table {table_name} créée")
|
|
85
|
+
# Drop rows where all values are null (polars equivalent)
|
|
86
|
+
df = df.filter(~pl.all_horizontal(pl.all().is_null()))
|
|
87
|
+
overwrite_table_with_csv_data(
|
|
88
|
+
engine, df, table_name, batch_size=config.get("batch_size", 10000)
|
|
89
|
+
)
|
|
90
|
+
print(f"Données du fichier {file_path} chargées dans la table {table_name}")
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Expose les connecteurs pour un import simple
|
|
2
|
+
from .postgres import PostgresConnector as PostgresConnector
|
|
3
|
+
from .mysql import MySQLConnector as MySQLConnector
|
|
4
|
+
from .mssql import MSSQLConnector as MSSQLConnector
|
|
5
|
+
from .oracle import OracleConnector as OracleConnector
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from sqlalchemy import URL, create_engine
|
|
2
|
+
from csv_to_db_py.db_connectors.base import BaseDBConnector
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class MSSQLConnector(BaseDBConnector):
|
|
6
|
+
def connect(self):
|
|
7
|
+
cfg = self.config
|
|
8
|
+
url = URL.create(
|
|
9
|
+
"mssql+pyodbc",
|
|
10
|
+
username=cfg["user"],
|
|
11
|
+
password=cfg["password"],
|
|
12
|
+
host=cfg["host"],
|
|
13
|
+
port=cfg["port"],
|
|
14
|
+
database=cfg["database"],
|
|
15
|
+
query={"driver": "ODBC Driver 17 for SQL Server"},
|
|
16
|
+
)
|
|
17
|
+
self.engine = create_engine(url)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from sqlalchemy import URL, create_engine
|
|
2
|
+
from csv_to_db_py.db_connectors.base import BaseDBConnector
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class MySQLConnector(BaseDBConnector):
|
|
6
|
+
def connect(self):
|
|
7
|
+
cfg = self.config
|
|
8
|
+
url = URL.create(
|
|
9
|
+
"mysql+pymysql",
|
|
10
|
+
username=cfg["user"],
|
|
11
|
+
password=cfg["password"],
|
|
12
|
+
host=cfg["host"],
|
|
13
|
+
port=cfg["port"],
|
|
14
|
+
database=cfg["database"],
|
|
15
|
+
)
|
|
16
|
+
self.engine = create_engine(url)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from sqlalchemy import URL, create_engine
|
|
2
|
+
from csv_to_db_py.db_connectors.base import BaseDBConnector
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class OracleConnector(BaseDBConnector):
|
|
6
|
+
def connect(self):
|
|
7
|
+
cfg = self.config
|
|
8
|
+
# DSN Oracle: host:port/service_name (ex: localhost:1521/XEPDB1)
|
|
9
|
+
url = URL.create(
|
|
10
|
+
"oracle+oracledb",
|
|
11
|
+
username=cfg["user"],
|
|
12
|
+
password=cfg["password"],
|
|
13
|
+
host=cfg["host"],
|
|
14
|
+
port=cfg["port"],
|
|
15
|
+
database=cfg["database"],
|
|
16
|
+
)
|
|
17
|
+
self.engine = create_engine(url)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from sqlalchemy import URL, create_engine
|
|
2
|
+
from csv_to_db_py.db_connectors.base import BaseDBConnector
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class PostgresConnector(BaseDBConnector):
|
|
6
|
+
def connect(self):
|
|
7
|
+
cfg = self.config
|
|
8
|
+
# url = f"postgresql+psycopg2://{cfg['user']}:{cfg['password']}@{cfg['host']}:{cfg['port']}/{cfg['database']}"
|
|
9
|
+
url = URL.create(
|
|
10
|
+
"postgresql+psycopg2",
|
|
11
|
+
username=cfg["user"],
|
|
12
|
+
password=cfg["password"],
|
|
13
|
+
host=cfg["host"],
|
|
14
|
+
port=cfg["port"],
|
|
15
|
+
database=cfg["database"],
|
|
16
|
+
)
|
|
17
|
+
self.engine = create_engine(url)
|
csv_to_db_py/logger.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from rich.logging import RichHandler
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def set_logger(level: str):
|
|
6
|
+
logging.basicConfig(
|
|
7
|
+
format="%(message)s",
|
|
8
|
+
level=level.upper(),
|
|
9
|
+
handlers=[RichHandler()],
|
|
10
|
+
)
|
|
11
|
+
logger = logging.getLogger()
|
|
12
|
+
return logger
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_logger():
|
|
16
|
+
return logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
logger = get_logger()
|
csv_to_db_py/main.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from csv_to_db_py.config import load_config
|
|
5
|
+
from csv_to_db_py.core import load_csv_to_db
|
|
6
|
+
from csv_to_db_py.logger import set_logger
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_args(argv) -> argparse.Namespace:
|
|
10
|
+
parser = argparse.ArgumentParser(
|
|
11
|
+
prog="csv-to-db",
|
|
12
|
+
description="Load CSV/XLSX files into a database table",
|
|
13
|
+
)
|
|
14
|
+
parser.add_argument("input_path", nargs="?", default=None)
|
|
15
|
+
parser.add_argument("--config", default="mainConfig.json")
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"--log-level",
|
|
18
|
+
default="info",
|
|
19
|
+
choices=["debug", "info", "warning", "error"],
|
|
20
|
+
)
|
|
21
|
+
return parser.parse_args(argv)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main(argv=None) -> None:
|
|
25
|
+
args = parse_args(sys.argv[1:] if argv is None else argv)
|
|
26
|
+
set_logger(args.log_level)
|
|
27
|
+
cfg = load_config(args.config)
|
|
28
|
+
load_csv_to_db(cfg, args.input_path)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if __name__ == "__main__":
|
|
32
|
+
main()
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Expose les fonctions utiles si tu veux les importer facilement
|
|
2
|
+
from .table_creator import create_table_from_csv as create_table_from_csv
|
|
3
|
+
from .csv_loader import overwrite_table_with_csv_data as overwrite_table_with_csv_data
|
|
4
|
+
from .cleaned_csv import (
|
|
5
|
+
get_dataframe_cleaned_new as get_dataframe_cleaned_new,
|
|
6
|
+
normalize_column as normalize_column,
|
|
7
|
+
)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from collections import Counter
|
|
2
|
+
import polars as pl
|
|
3
|
+
import re
|
|
4
|
+
from csv_to_db_py.config import PG_RESERVED
|
|
5
|
+
from csv_to_db_py.logger import logger
|
|
6
|
+
|
|
7
|
+
import csv
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def detect_delimiter(file_path):
|
|
11
|
+
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
12
|
+
sample = f.read(2048)
|
|
13
|
+
try:
|
|
14
|
+
delimiter = csv.Sniffer().sniff(sample).delimiter
|
|
15
|
+
except Exception:
|
|
16
|
+
delimiter = ";" # fallback si détection impossible : tu peux mettre "," si tu préfères
|
|
17
|
+
return delimiter
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def quote_identifier(name: str) -> str:
|
|
21
|
+
return '"' + name.replace('"', '""') + '"'
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def normalize_column(name: str) -> str:
|
|
25
|
+
reserved_word = Counter()
|
|
26
|
+
name = name.strip().lower() # lowercase + trim
|
|
27
|
+
name = re.sub(r"[^0-9a-zA-Z_]+", "_", name) # replace non-alphanum with "_"
|
|
28
|
+
name = re.sub(r"_+", "_", name) # collapse multiple "_"
|
|
29
|
+
name = name.strip("_") # remove leading/trailing "_"
|
|
30
|
+
|
|
31
|
+
# si le nom est un mot réservé SQL → ajouter suffixe incrémental
|
|
32
|
+
if name in PG_RESERVED:
|
|
33
|
+
reserved_word[name] += 1
|
|
34
|
+
name = f"{name}_{reserved_word[name]}"
|
|
35
|
+
|
|
36
|
+
return name
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_dataframe_cleaned_new(file_path, delimiter=None):
|
|
40
|
+
# --- 1) Lecture du fichier selon l'extension ---
|
|
41
|
+
if file_path.endswith(".xlsx"):
|
|
42
|
+
print(f"Lecture Excel : {file_path}")
|
|
43
|
+
df = pl.read_excel(file_path)
|
|
44
|
+
|
|
45
|
+
elif file_path.endswith(".csv"):
|
|
46
|
+
print(f"Lecture CSV : {file_path}")
|
|
47
|
+
|
|
48
|
+
if delimiter is None:
|
|
49
|
+
delimiter = detect_delimiter(file_path)
|
|
50
|
+
print(f"Délimiteur utilisé pour {file_path}: '{delimiter}'")
|
|
51
|
+
|
|
52
|
+
# Essayer UTF-8, sinon fallback Latin-1
|
|
53
|
+
try:
|
|
54
|
+
df = pl.read_csv(file_path, separator=delimiter, encoding="utf-8")
|
|
55
|
+
except UnicodeDecodeError:
|
|
56
|
+
print("[ENCODING] UTF-8 échoué, tentative avec Latin-1...")
|
|
57
|
+
df = pl.read_csv(file_path, separator=delimiter, encoding="latin1")
|
|
58
|
+
|
|
59
|
+
else:
|
|
60
|
+
raise ValueError(f"Format non supporté : {file_path}")
|
|
61
|
+
|
|
62
|
+
# --- 2) Nettoyage des colonnes ---
|
|
63
|
+
new_columns = [
|
|
64
|
+
col.replace(" ", "").replace(".", "").replace("/", "") for col in df.columns
|
|
65
|
+
]
|
|
66
|
+
new_columns = normalize_and_dedup_columns(new_columns)
|
|
67
|
+
df = df.rename(dict(zip(df.columns, new_columns)))
|
|
68
|
+
|
|
69
|
+
print(df)
|
|
70
|
+
return df
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def apply_filter(df: pl.DataFrame, filter_config: dict) -> pl.DataFrame:
|
|
74
|
+
filter_col = filter_config.get("column")
|
|
75
|
+
filter_val = filter_config.get("value")
|
|
76
|
+
if not filter_col or filter_val is None:
|
|
77
|
+
return df
|
|
78
|
+
normalized_col = normalize_column(filter_col)
|
|
79
|
+
if normalized_col not in df.columns:
|
|
80
|
+
logger.warning(
|
|
81
|
+
f"Filtre ignoré : colonne '{filter_col}' absente après normalisation."
|
|
82
|
+
)
|
|
83
|
+
return df
|
|
84
|
+
col = df[normalized_col]
|
|
85
|
+
try:
|
|
86
|
+
cast_val = pl.lit(filter_val).cast(col.dtype)
|
|
87
|
+
return df.filter(col == cast_val)
|
|
88
|
+
except Exception:
|
|
89
|
+
logger.warning(
|
|
90
|
+
f"Filtre ignoré : valeur '{filter_val}' non convertible pour la colonne '{filter_col}'."
|
|
91
|
+
)
|
|
92
|
+
return df
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def normalize_and_dedup_columns(columns):
|
|
96
|
+
seen = Counter()
|
|
97
|
+
new_cols = []
|
|
98
|
+
for col in columns:
|
|
99
|
+
normed = normalize_column(col) # ta fonction de normalisation
|
|
100
|
+
seen[normed] += 1
|
|
101
|
+
if seen[normed] == 1:
|
|
102
|
+
new_cols.append(normed)
|
|
103
|
+
else:
|
|
104
|
+
new_cols.append(f"{normed}_{seen[normed]}")
|
|
105
|
+
return new_cols
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import polars as pl
|
|
2
|
+
from sqlalchemy import inspect, text
|
|
3
|
+
from csv_to_db_py.logger import logger
|
|
4
|
+
from csv_to_db_py.utils.cleaned_csv import quote_identifier
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _truncate_if_exists(engine, table_name, expected_columns):
|
|
8
|
+
"""TRUNCATE if table exists → preserve schema & dependent views. Returns True if truncated."""
|
|
9
|
+
inspector = inspect(engine)
|
|
10
|
+
if not inspector.has_table(table_name):
|
|
11
|
+
return False
|
|
12
|
+
db_columns = [column["name"] for column in inspector.get_columns(table_name)]
|
|
13
|
+
if sorted(db_columns) != sorted(expected_columns):
|
|
14
|
+
# ponytail: column-name drift is guarded; TYPE drift is not (still fails loudly after truncate)
|
|
15
|
+
raise ValueError(
|
|
16
|
+
f"Schema drift on table {table_name!r}: existing columns {sorted(db_columns)} "
|
|
17
|
+
f"differ from CSV columns {sorted(expected_columns)}. "
|
|
18
|
+
"DROP TABLE ... CASCADE and recreate dependent views, or align the CSV columns."
|
|
19
|
+
)
|
|
20
|
+
with engine.begin() as conn:
|
|
21
|
+
conn.execute(text(f"TRUNCATE TABLE {quote_identifier(table_name)}"))
|
|
22
|
+
return True
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def map_polars_dtype_to_postgresql(series):
|
|
26
|
+
dtype = series.dtype
|
|
27
|
+
dtype_str = str(dtype)
|
|
28
|
+
|
|
29
|
+
if "Int" in dtype_str:
|
|
30
|
+
return "INTEGER"
|
|
31
|
+
elif "Float" in dtype_str:
|
|
32
|
+
# Check if all non-null values are integers
|
|
33
|
+
non_null = series.drop_nulls()
|
|
34
|
+
if len(non_null) > 0 and (non_null == non_null.cast(pl.Int64)).all():
|
|
35
|
+
return "INTEGER"
|
|
36
|
+
else:
|
|
37
|
+
return "FLOAT"
|
|
38
|
+
elif "Boolean" in dtype_str or "Bool" in dtype_str:
|
|
39
|
+
return "BOOLEAN"
|
|
40
|
+
elif "Datetime" in dtype_str or "Date" in dtype_str:
|
|
41
|
+
return "TIMESTAMP"
|
|
42
|
+
elif "Utf8" in dtype_str or "String" in dtype_str:
|
|
43
|
+
return "VARCHAR"
|
|
44
|
+
else:
|
|
45
|
+
return "VARCHAR"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def detect_and_parse_datetime_column(dataframe, column_name, threshold=1.0):
|
|
49
|
+
"""
|
|
50
|
+
Détecte automatiquement si une colonne string contient des dates et trouve le format approprié.
|
|
51
|
+
Utilise l'inférence automatique de Polars qui supporte de nombreux formats.
|
|
52
|
+
|
|
53
|
+
Logique de validation:
|
|
54
|
+
- Filtre les nulls et chaînes vides (valeurs non significatives pour l'analyse)
|
|
55
|
+
- Parmi les valeurs "utiles" restantes, au moins `datetime_detection_threshold` doivent être des dates valides
|
|
56
|
+
- Les valeurs invalides sont converties en null automatiquement (strict=False)
|
|
57
|
+
- Préserve tous les nulls et chaînes vides originaux
|
|
58
|
+
"""
|
|
59
|
+
column = dataframe[column_name]
|
|
60
|
+
dtype_str = str(column.dtype)
|
|
61
|
+
|
|
62
|
+
# Only process string columns
|
|
63
|
+
if "Utf8" not in dtype_str and "String" not in dtype_str:
|
|
64
|
+
return dataframe
|
|
65
|
+
|
|
66
|
+
# Filter out nulls AND empty strings from original data
|
|
67
|
+
# Ces valeurs ne sont pas significatives pour déterminer si c'est une colonne de dates
|
|
68
|
+
useful_values = column.filter(
|
|
69
|
+
(column.is_not_null()) & (column.str.strip_chars() != "")
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
num_useful = len(useful_values)
|
|
73
|
+
if num_useful == 0:
|
|
74
|
+
return dataframe
|
|
75
|
+
|
|
76
|
+
# Seuil configurable pour la détection de dates (défaut: 1.0 = 100%, désactivé)
|
|
77
|
+
MIN_SUCCESS_RATE = threshold
|
|
78
|
+
|
|
79
|
+
# Try automatic datetime parsing with Polars (supports many formats automatically)
|
|
80
|
+
try:
|
|
81
|
+
# Polars can auto-detect many datetime formats
|
|
82
|
+
# strict=False means invalid values become null instead of raising errors
|
|
83
|
+
parsed = dataframe.with_columns(
|
|
84
|
+
pl.col(column_name).str.to_datetime(strict=False)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Count how many useful values were successfully parsed as dates
|
|
88
|
+
# On compte les dates parsées DANS LE DATAFRAME ORIGINAL (pour exclure les nulls initiaux)
|
|
89
|
+
parsed_col = parsed[column_name]
|
|
90
|
+
|
|
91
|
+
# On doit compter combien de valeurs utiles ont été converties en dates
|
|
92
|
+
# Pour cela, on regarde la colonne parsée aux mêmes positions que les valeurs utiles
|
|
93
|
+
original_indices = (
|
|
94
|
+
column.to_frame()
|
|
95
|
+
.with_row_count("idx")
|
|
96
|
+
.filter((column.is_not_null()) & (column.str.strip_chars() != ""))["idx"]
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
parsed_useful = parsed_col.gather(original_indices)
|
|
100
|
+
parsed_useful = parsed_useful.filter(parsed_useful.is_not_null())
|
|
101
|
+
num_parsed = len(parsed_useful)
|
|
102
|
+
|
|
103
|
+
success_rate = num_parsed / num_useful
|
|
104
|
+
|
|
105
|
+
# Si au moins la part configurée des valeurs utiles est parsée, on convertit
|
|
106
|
+
if success_rate >= MIN_SUCCESS_RATE:
|
|
107
|
+
num_invalid = num_useful - num_parsed
|
|
108
|
+
logger.info(
|
|
109
|
+
f"Colonne '{column_name}' détectée comme datetime (format auto-détecté) - "
|
|
110
|
+
f"{num_parsed}/{num_useful} valeurs utiles parsées ({success_rate:.1%}), "
|
|
111
|
+
f"{num_invalid} valeur(s) invalide(s) convertie(s) en null"
|
|
112
|
+
)
|
|
113
|
+
return parsed
|
|
114
|
+
except Exception as e:
|
|
115
|
+
logger.debug(f"Auto-détection datetime échouée pour '{column_name}': {e}")
|
|
116
|
+
|
|
117
|
+
# Try common date formats manually if auto-detection fails
|
|
118
|
+
common_formats = [
|
|
119
|
+
# ISO formats (YYYY-MM-DD)
|
|
120
|
+
"%Y-%m-%d",
|
|
121
|
+
"%Y-%m-%d %H:%M:%S",
|
|
122
|
+
"%Y-%m-%dT%H:%M:%S",
|
|
123
|
+
"%Y-%m-%dT%H:%M:%S.%f",
|
|
124
|
+
# French/European formats (DD/MM/YYYY)
|
|
125
|
+
"%d/%m/%Y",
|
|
126
|
+
"%d/%m/%Y %H:%M:%S",
|
|
127
|
+
"%d/%m/%y",
|
|
128
|
+
# American formats (MM/DD/YYYY) - with AM/PM support
|
|
129
|
+
"%m/%d/%Y",
|
|
130
|
+
"%m/%d/%Y %H:%M:%S",
|
|
131
|
+
"%m/%d/%Y %I:%M:%S %p", # 12-hour format with AM/PM
|
|
132
|
+
# European dot format (DD.MM.YYYY)
|
|
133
|
+
"%d.%m.%Y",
|
|
134
|
+
"%d.%m.%Y %H:%M:%S",
|
|
135
|
+
# Other formats
|
|
136
|
+
"%d-%m-%Y",
|
|
137
|
+
"%d-%m-%Y %H:%M:%S",
|
|
138
|
+
"%d-%m-%y",
|
|
139
|
+
"%Y/%m/%d",
|
|
140
|
+
"%Y/%m/%d %H:%M:%S",
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
for date_format in common_formats:
|
|
144
|
+
try:
|
|
145
|
+
# strict=False: invalid values become null
|
|
146
|
+
parsed = dataframe.with_columns(
|
|
147
|
+
pl.col(column_name).str.strptime(
|
|
148
|
+
pl.Datetime, format=date_format, strict=False
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Same logic as above
|
|
153
|
+
parsed_col = parsed[column_name]
|
|
154
|
+
original_indices = (
|
|
155
|
+
column.to_frame()
|
|
156
|
+
.with_row_count("idx")
|
|
157
|
+
.filter((column.is_not_null()) & (column.str.strip_chars() != ""))[
|
|
158
|
+
"idx"
|
|
159
|
+
]
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
parsed_useful = parsed_col.gather(original_indices)
|
|
163
|
+
parsed_useful = parsed_useful.filter(parsed_useful.is_not_null())
|
|
164
|
+
num_parsed = len(parsed_useful)
|
|
165
|
+
success_rate = num_parsed / num_useful
|
|
166
|
+
|
|
167
|
+
# Si au moins la part configurée des valeurs utiles est parsée, on accepte
|
|
168
|
+
if success_rate >= MIN_SUCCESS_RATE:
|
|
169
|
+
num_invalid = num_useful - num_parsed
|
|
170
|
+
logger.info(
|
|
171
|
+
f"Colonne '{column_name}' détectée comme datetime (format: {date_format}) - "
|
|
172
|
+
f"{num_parsed}/{num_useful} valeurs utiles parsées ({success_rate:.1%}), "
|
|
173
|
+
f"{num_invalid} valeur(s) invalide(s) convertie(s) en null"
|
|
174
|
+
)
|
|
175
|
+
return parsed
|
|
176
|
+
except Exception:
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
# If no format worked, return original dataframe
|
|
180
|
+
# Les nulls et valeurs vides sont préservés dans tous les cas
|
|
181
|
+
return dataframe
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def convert_datetime_columns(dataframe, threshold=1.0):
|
|
185
|
+
"""Convert every string column that looks like a date, using the given detection threshold."""
|
|
186
|
+
for column_name in dataframe.columns:
|
|
187
|
+
dataframe = detect_and_parse_datetime_column(
|
|
188
|
+
dataframe, column_name, threshold=threshold
|
|
189
|
+
)
|
|
190
|
+
return dataframe
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def postgrestype_dict(dataframe, sample_size=10000):
|
|
194
|
+
"""
|
|
195
|
+
Détermine les types PostgreSQL pour chaque colonne du DataFrame.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
dataframe: DataFrame Polars
|
|
199
|
+
sample_size: Taille maximale de l'échantillon pour l'analyse des types.
|
|
200
|
+
Si le DataFrame est plus grand, un échantillon aléatoire est utilisé.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
dict: Mapping {nom_colonne: type_postgresql}
|
|
204
|
+
"""
|
|
205
|
+
column_types = {}
|
|
206
|
+
|
|
207
|
+
# Use sample if dataframe is larger than sample_size
|
|
208
|
+
num_rows = len(dataframe)
|
|
209
|
+
if num_rows > sample_size:
|
|
210
|
+
logger.info(
|
|
211
|
+
f"DataFrame contient {num_rows} lignes. Utilisation d'un échantillon aléatoire "
|
|
212
|
+
f"de {sample_size} lignes pour la détection des types."
|
|
213
|
+
)
|
|
214
|
+
df_sample = dataframe.sample(n=sample_size, seed=42)
|
|
215
|
+
else:
|
|
216
|
+
df_sample = dataframe
|
|
217
|
+
|
|
218
|
+
for column_name in df_sample.columns:
|
|
219
|
+
column_types[column_name] = map_polars_dtype_to_postgresql(
|
|
220
|
+
df_sample[column_name]
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return column_types
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def overwrite_table_with_csv_data(engine, csv_data, table_name, batch_size=10000):
|
|
227
|
+
# Convert polars DataFrame to pandas for SQLAlchemy compatibility
|
|
228
|
+
pandas_data = csv_data.to_pandas()
|
|
229
|
+
|
|
230
|
+
total_rows = len(pandas_data)
|
|
231
|
+
|
|
232
|
+
# Typed table always pre-created (create_table_from_csv raises on failure); to_sql only appends.
|
|
233
|
+
truncated = _truncate_if_exists(engine, table_name, csv_data.columns)
|
|
234
|
+
|
|
235
|
+
if total_rows <= batch_size:
|
|
236
|
+
pandas_data.to_sql(name=table_name, con=engine, if_exists="append", index=False)
|
|
237
|
+
logger.info(
|
|
238
|
+
f"Table '{table_name}' {'vidée puis remplie' if truncated else 'créée'} "
|
|
239
|
+
f"avec {total_rows} lignes (insertion unique)"
|
|
240
|
+
)
|
|
241
|
+
else:
|
|
242
|
+
# Multi-batch insertion
|
|
243
|
+
num_batches = (total_rows + batch_size - 1) // batch_size # Ceiling division
|
|
244
|
+
logger.info(
|
|
245
|
+
f"Insertion de {total_rows} lignes dans '{table_name}' par batch de {batch_size} "
|
|
246
|
+
f"({num_batches} batch(s))"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
for i in range(0, total_rows, batch_size):
|
|
250
|
+
batch_num = (i // batch_size) + 1
|
|
251
|
+
end_idx = min(i + batch_size, total_rows)
|
|
252
|
+
batch_data = pandas_data.iloc[i:end_idx]
|
|
253
|
+
|
|
254
|
+
batch_data.to_sql(
|
|
255
|
+
name=table_name, con=engine, if_exists="append", index=False
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
logger.info(
|
|
259
|
+
f" Batch {batch_num}/{num_batches}: {len(batch_data)} lignes insérées "
|
|
260
|
+
f"(lignes {i + 1} à {end_idx})"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
logger.info(
|
|
264
|
+
f"Insertion terminée: {total_rows} lignes insérées dans '{table_name}'"
|
|
265
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from csv_to_db_py.logger import logger
|
|
2
|
+
from csv_to_db_py.utils.cleaned_csv import quote_identifier
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def create_table_from_csv(engine, dataframe, table, types_dict):
|
|
6
|
+
# Génération de la requête SQL pour créer la table
|
|
7
|
+
# query = f"CREATE TABLE {table} ({', '.join([column + ' '+types_dict[column] for column in columns])});"
|
|
8
|
+
columns = dataframe.columns
|
|
9
|
+
query = f"""CREATE TABLE IF NOT EXISTS {quote_identifier(table)} ({", ".join([quote_identifier(column) + " " + types_dict[column] for column in columns])});"""
|
|
10
|
+
with engine.begin() as conn:
|
|
11
|
+
conn.execute(query)
|
|
12
|
+
|
|
13
|
+
logger.info(f"Table '{table}' crée")
|
|
14
|
+
|
|
15
|
+
columns = dataframe.columns
|
|
16
|
+
|
|
17
|
+
logger.debug(columns)
|
|
18
|
+
|
|
19
|
+
logger.debug(query)
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: csv-to-db-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Convert CSV file or folder of CSV to DB with table filled with the CSV data
|
|
5
|
+
Author: Karim Ounnoughi, Claude Fauconnet, Juste Bonzene
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE.md
|
|
8
|
+
Requires-Python: >=3.13
|
|
9
|
+
Requires-Dist: fastexcel>=0.20.2
|
|
10
|
+
Requires-Dist: openpyxl<4.0.0,>=3.1.5
|
|
11
|
+
Requires-Dist: oracledb<3.0.0,>=2.0.0
|
|
12
|
+
Requires-Dist: pandas<3.0.0,>=2.3.2
|
|
13
|
+
Requires-Dist: polars>=1.20.0
|
|
14
|
+
Requires-Dist: psycopg2<3.0.0,>=2.9.10
|
|
15
|
+
Requires-Dist: pyarrow<19.0.0,>=18.0.0
|
|
16
|
+
Requires-Dist: pymysql<2.0.0,>=1.1.0
|
|
17
|
+
Requires-Dist: pyodbc<6.0.0,>=5.0.0
|
|
18
|
+
Requires-Dist: requests<3.0.0,>=2.32.5
|
|
19
|
+
Requires-Dist: rich<15.0.0,>=14.1.0
|
|
20
|
+
Requires-Dist: sqlalchemy<3.0.0,>=2.0.43
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# CSV to DB
|
|
24
|
+
|
|
25
|
+
Convert table files or folders to PostgreSQL database tables.The database need to already exists. It have a feature of autodection of types.
|
|
26
|
+
-dates (multiples formats cited on tests parts)
|
|
27
|
+
-int
|
|
28
|
+
-float
|
|
29
|
+
-string
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
## Prerequisites
|
|
33
|
+
|
|
34
|
+
- Python 3.13+
|
|
35
|
+
- UV (https://docs.astral.sh/uv/getting-started/installation/)
|
|
36
|
+
- PostgreSQL database
|
|
37
|
+
- files with CSV or XLSX format
|
|
38
|
+
- MySQL/MSSQL/Oracle connections rely on bundled drivers: `pymysql`, `pyodbc`, `oracledb`
|
|
39
|
+
- `pyodbc` additionally requires an ODBC driver; `oracledb` runs in thin mode (no Oracle client needed)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
git clone https://github.com/ounnoughikarim/CSV_TO_DB_PY.git
|
|
47
|
+
cd CSV_TO_DB_PY
|
|
48
|
+
uv sync
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Install from PyPI
|
|
52
|
+
|
|
53
|
+
The package is published to PyPI and can be installed with:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
uv add csv-to-db-py
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
or with pip:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pip install csv-to-db-py
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Publishing
|
|
66
|
+
|
|
67
|
+
To publish a new release to PyPI, push a tag matching the version in `pyproject.toml`:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
git tag 0.1.0
|
|
71
|
+
git push origin 0.1.0
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The `publish` GitHub Actions workflow builds the package and publishes it to PyPI via trusted publishing.
|
|
75
|
+
|
|
76
|
+
## Configuration
|
|
77
|
+
|
|
78
|
+
Create `mainConfig.json` from the template:
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"DATABASE_CONFIG": {
|
|
83
|
+
"type": "postgres",
|
|
84
|
+
"host": "hostname",
|
|
85
|
+
"port": 5432,
|
|
86
|
+
"user": "postgres",
|
|
87
|
+
"password": "your_password",
|
|
88
|
+
"database": "your_database"
|
|
89
|
+
},
|
|
90
|
+
"files_dir": "C:\\path\\to\\your\\file",
|
|
91
|
+
"table": "table_name",
|
|
92
|
+
"delimiter": ";",
|
|
93
|
+
"datetime_detection_threshold": 1.0,
|
|
94
|
+
"batch_size": 100000
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Configuration Parameters
|
|
99
|
+
|
|
100
|
+
- `DATABASE_CONFIG`: Database connection settings
|
|
101
|
+
- `type`: Database type (postgres, mysql, mssql, oracle)
|
|
102
|
+
- `host`: Database hostname
|
|
103
|
+
- `port`: Database port
|
|
104
|
+
- `user`: Database username
|
|
105
|
+
- `password`: Database password
|
|
106
|
+
- `database`: Database name
|
|
107
|
+
- `files_dir`: Path to a file or folder containing multiple files (CSV or XLSX)
|
|
108
|
+
- `table`: Table name (optional - uses filename if not specified for single file)
|
|
109
|
+
- `delimiter`: CSV delimiter character (e.g., ";", ",", "\t")
|
|
110
|
+
- `datetime_detection_threshold`: *(optional)* Threshold for automatic datetime detection
|
|
111
|
+
- Range: `0.0` to `1.0` (0% to 100%)
|
|
112
|
+
- Default: `1.0` (100%, strict - only perfect matches)
|
|
113
|
+
- Recommended: `0.7` (70% threshold for flexible detection)
|
|
114
|
+
- A column is detected as datetime if at least X% of non-null, non-empty values can be parsed as valid dates
|
|
115
|
+
- Invalid values are automatically converted to NULL
|
|
116
|
+
- **Examples:**
|
|
117
|
+
- `1.0`: Only columns with 100% valid dates are detected (safest, default)
|
|
118
|
+
- `0.9`: Allows up to 10% invalid date values
|
|
119
|
+
- `0.7`: Allows up to 30% invalid date values (recommended for real-world data)
|
|
120
|
+
- `batch_size`: *(optional)* Number of rows to insert per batch for large files
|
|
121
|
+
- Default: `10000` (10k rows per batch)
|
|
122
|
+
- Reduces memory usage for large datasets
|
|
123
|
+
- SQLAlchemy can struggle with very large files, batching prevents memory issues
|
|
124
|
+
- **Recommended values:**
|
|
125
|
+
- `100000`: Good for most cases (files up to several GB)
|
|
126
|
+
- `50000`: For memory-constrained environments
|
|
127
|
+
- `10000`: Default (10k rows per batch), good balance of speed and memory
|
|
128
|
+
- `500000`: For high-memory systems and faster insertions
|
|
129
|
+
|
|
130
|
+
## Usage
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
uv run csv-to-db --config mainConfig.json
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
With custom log level:
|
|
137
|
+
```bash
|
|
138
|
+
uv run csv-to-db --config mainConfig.json --log-level info
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Testing
|
|
142
|
+
|
|
143
|
+
Run the test suite with pytest:
|
|
144
|
+
```bash
|
|
145
|
+
uv run pytest
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
This suite tests automatic datetime detection with various date formats, using [tests/date_formats_test.csv](tests/date_formats_test.csv) containing 14 columns:
|
|
149
|
+
|
|
150
|
+
**Date columns (should be detected as datetime):**
|
|
151
|
+
- **FR_date**: French format (DD/MM/YYYY)
|
|
152
|
+
- **FR_datetime**: French format with time (DD/MM/YYYY HH:MM:SS)
|
|
153
|
+
- **ISO_date**: ISO 8601 date (YYYY-MM-DD)
|
|
154
|
+
- **ISO_datetime**: ISO 8601 with time (YYYY-MM-DDTHH:MM:SS)
|
|
155
|
+
- **USA_date**: American format (MM/DD/YYYY)
|
|
156
|
+
- **USA_datetime**: American format with 12-hour time (MM/DD/YYYY hh:mm:ss AM/PM)
|
|
157
|
+
- **EUR_date**: European format (DD.MM.YYYY)
|
|
158
|
+
- **EUR_datetime**: European format with time (DD.MM.YYYY HH:MM:SS)
|
|
159
|
+
- **ISO_time**: ISO format with space separator (YYYY-MM-DD HH:MM:SS)
|
|
160
|
+
|
|
161
|
+
**Non-date columns (should NOT be detected as datetime):**
|
|
162
|
+
- **int_column**: Integer values
|
|
163
|
+
- **string_column**: Pure text strings
|
|
164
|
+
- **mixed_column**: Mix of text with 1-2 dates (should not be detected as datetime)
|
|
165
|
+
- **float_column**: Floating point numbers
|
|
166
|
+
- **bool_column**: Boolean values
|
|
167
|
+
|
|
168
|
+
The test demonstrates automatic format detection, handling of null values, conversion of invalid values to null, and proper rejection of non-date columns.
|
|
169
|
+
|
|
170
|
+
### Batch Insertion for Large Files
|
|
171
|
+
|
|
172
|
+
For large datasets, the tool automatically splits data insertion into batches to prevent memory issues:
|
|
173
|
+
|
|
174
|
+
**How it works:**
|
|
175
|
+
- If total rows ≤ `batch_size`: Single insertion (fastest)
|
|
176
|
+
- If total rows > `batch_size`: Multiple insertions in batches
|
|
177
|
+
|
|
178
|
+
**Example with 250,000 rows and `batch_size: 100000`:**
|
|
179
|
+
```
|
|
180
|
+
Pre-step: TRUNCATE table if it already exists (preserves schema & dependent views)
|
|
181
|
+
Batch 1: Rows 1 to 100,000 (100,000 rows) - APPEND
|
|
182
|
+
Batch 2: Rows 100,001 to 200,000 (100,000 rows) - APPEND
|
|
183
|
+
Batch 3: Rows 200,001 to 250,000 (50,000 rows) - APPEND
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
**Benefits:**
|
|
187
|
+
- ✅ Prevents memory overflow with large files
|
|
188
|
+
- ✅ Better progress tracking through logs
|
|
189
|
+
- ✅ Allows processing files larger than available RAM
|
|
190
|
+
- ✅ Automatic recovery possible (first batch creates table structure)
|
|
191
|
+
|
|
192
|
+
### Table Overwrite Strategy
|
|
193
|
+
|
|
194
|
+
When a table already exists, the tool **does not DROP** it. Instead it runs `TRUNCATE` then appends the new data.
|
|
195
|
+
|
|
196
|
+
**Why:**
|
|
197
|
+
|
|
198
|
+
- Dropping a table fails if a view (or other object) depends on it.
|
|
199
|
+
- TRUNCATE empties the rows while preserving the table schema and any dependent views/constraints.
|
|
200
|
+
|
|
201
|
+
**Behavior:**
|
|
202
|
+
|
|
203
|
+
- Table does not exist → pre-created by `create_table_from_csv` with detected types.
|
|
204
|
+
- Table exists → `TRUNCATE TABLE "<name>"` then `INSERT` (append mode). If the existing table's columns differ from the CSV (schema drift), the run aborts with a clear error **before truncating**, so the old data is preserved.
|
|
205
|
+
|
|
206
|
+
**Caveat — schema drift:**
|
|
207
|
+
|
|
208
|
+
- TRUNCATE keeps the **existing** column definitions. Column-name drift (added/removed/renamed columns) is detected and blocks the overwrite before any data is lost. Type drift is not detected and still fails loudly after truncate.
|
|
209
|
+
- To load a CSV whose schema differs, manually `DROP TABLE ... CASCADE` (and recreate dependent views) or alter the table to match the new schema.
|
|
210
|
+
|
|
211
|
+
**Performance tips:**
|
|
212
|
+
- Larger batch sizes = faster insertion but more memory usage
|
|
213
|
+
- Smaller batch sizes = slower but safer for limited memory
|
|
214
|
+
- Default 10k is optimal for most use cases
|
|
215
|
+
|
|
216
|
+
### Type Detection Optimization (Sampling)
|
|
217
|
+
|
|
218
|
+
For large datasets, type detection automatically uses random sampling to improve performance:
|
|
219
|
+
|
|
220
|
+
**How it works:**
|
|
221
|
+
- If DataFrame has ≤ 10,000 rows: Full dataset is analyzed
|
|
222
|
+
- If DataFrame has > 10,000 rows: Random sample of 10,000 rows is used
|
|
223
|
+
- Sample uses fixed seed (42) for reproducibility
|
|
224
|
+
- Applies to both type detection and datetime format detection
|
|
225
|
+
|
|
226
|
+
**Benefits:**
|
|
227
|
+
- ✅ Significantly faster type detection for large files (millions of rows)
|
|
228
|
+
- ✅ Sample size of 10,000 rows provides excellent type accuracy
|
|
229
|
+
- ✅ Reduces processing time without compromising quality
|
|
230
|
+
- ✅ Transparent logging when sampling is used
|
|
231
|
+
|
|
232
|
+
**Example:**
|
|
233
|
+
```
|
|
234
|
+
DataFrame contient 5,000,000 lignes. Utilisation d'un échantillon aléatoire de 10000 lignes pour la détection des types.
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
**Note:** This optimization only affects type detection. The full dataset is still loaded and inserted into the database.
|
|
238
|
+
|
|
239
|
+
### Datetime Detection Behavior
|
|
240
|
+
|
|
241
|
+
The datetime detection threshold controls how strict the algorithm is when detecting date columns:
|
|
242
|
+
|
|
243
|
+
**With `datetime_detection_threshold: 1.0` (default, 100%):**
|
|
244
|
+
- Only columns where **ALL** non-null, non-empty values are valid dates will be detected
|
|
245
|
+
- This is the safest option to avoid false positives
|
|
246
|
+
- Use this when you want explicit control over which columns are dates
|
|
247
|
+
|
|
248
|
+
**With `datetime_detection_threshold: 0.7` (recommended for flexible detection):**
|
|
249
|
+
- Columns where **at least 70%** of non-null, non-empty values are valid dates will be detected
|
|
250
|
+
- Invalid values are automatically converted to NULL
|
|
251
|
+
- Useful when your data might contain occasional typos or invalid date entries
|
|
252
|
+
- Example: A column with 9 valid dates and 1 "invalid" string → detected as datetime
|
|
253
|
+
|
|
254
|
+
**Example scenarios:**
|
|
255
|
+
```
|
|
256
|
+
Column: ["2024-01-01", "2024-01-02", "invalid", null, ""]
|
|
257
|
+
- 3 non-null, non-empty values total
|
|
258
|
+
- 2 valid dates (66.7%)
|
|
259
|
+
- With threshold 1.0: NOT detected (66.7% < 100%)
|
|
260
|
+
- With threshold 0.7: NOT detected (66.7% < 70%)
|
|
261
|
+
|
|
262
|
+
Column: ["2024-01-01", "2024-01-02", "2024-01-03", "invalid", null]
|
|
263
|
+
- 4 non-null, non-empty values total
|
|
264
|
+
- 3 valid dates (75%)
|
|
265
|
+
- With threshold 1.0: NOT detected (75% < 100%)
|
|
266
|
+
- With threshold 0.7: DETECTED (75% ≥ 70%), "invalid" becomes NULL
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## Development
|
|
270
|
+
|
|
271
|
+
Check code before committing:
|
|
272
|
+
```bash
|
|
273
|
+
uv run ruff check . --fix
|
|
274
|
+
uv run ruff check .
|
|
275
|
+
uv run ruff format
|
|
276
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
csv_to_db_py/__init__.py,sha256=wHSdd1uCl2Z9KWUYYOy1SjUIjTiZYkETXaEzf9OYdv0,166
|
|
2
|
+
csv_to_db_py/config.py,sha256=ThLnOjgWFlKQz_AV8e0Ig_bgGjT-BR8BUNLccQ24Bds,986
|
|
3
|
+
csv_to_db_py/core.py,sha256=gyeeENYIqVr2Uh-RKJFKO1BSYsgok_6-s0swIiwOc4o,3326
|
|
4
|
+
csv_to_db_py/logger.py,sha256=WAZ4TyG4_7i9brjeUqkZSKWN9-fvsXXSwf18cL2Ayp0,340
|
|
5
|
+
csv_to_db_py/main.py,sha256=M-uvY-sUVMmT00JmPmriJWJJNuwkE2eHhcDZ6tWXD6I,875
|
|
6
|
+
csv_to_db_py/db_connectors/__init__.py,sha256=9tMXKzesBuar1Vt1ZfviYDxPehZLRc1YrU06YCDcyPA,267
|
|
7
|
+
csv_to_db_py/db_connectors/base.py,sha256=09VWv1-VIVX0_-3rWWqhH4Cw-zIoFiqYq62UHmYwcFM,265
|
|
8
|
+
csv_to_db_py/db_connectors/mssql.py,sha256=_--AcjHDYEB1tpP3H4atA9SAktCeOVZx4WvFU8veRxg,530
|
|
9
|
+
csv_to_db_py/db_connectors/mysql.py,sha256=vdEw5wETA4qv6QmAUibRHNRjPS6eopJ_SiuuJ-EHmko,468
|
|
10
|
+
csv_to_db_py/db_connectors/oracle.py,sha256=h7bKg9rf8xMWutYoeLAM2-9_gyyw7mEgMiD6k_t0rhk,544
|
|
11
|
+
csv_to_db_py/db_connectors/postgres.py,sha256=w8gayoPoOAIvRr86qbvPiB506U8nD1cy5ZP41ASuUFY,596
|
|
12
|
+
csv_to_db_py/utils/__init__.py,sha256=FRBa1lAxuuXDF8ZxaU5BQsnQ3e-jlaqVpid_33_n5Fg,357
|
|
13
|
+
csv_to_db_py/utils/cleaned_csv.py,sha256=13qRbegpXdWMEZ0vncUO37HAy3SwvVbAdt9ojb8foD4,3426
|
|
14
|
+
csv_to_db_py/utils/csv_loader.py,sha256=YMCZZKVW-PoL5wg1XxG8cF5VpxoFZMpctiB1iVUI2LU,10241
|
|
15
|
+
csv_to_db_py/utils/table_creator.py,sha256=O2fszUoGd-6ONGdERUagRPnR0wDA-JsXvnH5pEMaT20,720
|
|
16
|
+
csv_to_db_py-0.1.0.dist-info/METADATA,sha256=PfyE9KWW3fWxxSIMSCESCZteNlZoPbGBY6XMcHKTZVg,9864
|
|
17
|
+
csv_to_db_py-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
18
|
+
csv_to_db_py-0.1.0.dist-info/entry_points.txt,sha256=5XTyokDAkfbkSQM0uIfW1IkKfHM6lKHcthbNTVMBGiQ,53
|
|
19
|
+
csv_to_db_py-0.1.0.dist-info/licenses/LICENSE.md,sha256=xrhxfiSLPm17ApyK9kdDxAMHUYSN1zw-2JRkxvrp2qo,1073
|
|
20
|
+
csv_to_db_py-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 OUNNOUGHI KARIM
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
|
22
|
+
|