@an5/adapters 0.2.5 → 0.2.9

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.
Files changed (99) hide show
  1. package/README.md +283 -305
  2. package/dist/an5Adapter.d.ts +142 -7
  3. package/dist/an5Adapter.js +998 -68
  4. package/dist/base/metadata.d.ts +10 -0
  5. package/dist/base/metadata.js +107 -0
  6. package/dist/base/sql.d.ts +19 -1
  7. package/dist/base/sql.js +152 -24
  8. package/dist/base/types.d.ts +10 -1
  9. package/dist/browser.d.ts +4 -1
  10. package/dist/browser.js +9 -2
  11. package/dist/googlesheets/adapter.d.ts +1 -0
  12. package/dist/googlesheets/adapter.js +9 -2
  13. package/dist/googlesheets/config.d.ts +4 -4
  14. package/dist/googlesheets/helpers.js +2 -0
  15. package/dist/googlesheets/parseConnectionString.js +5 -2
  16. package/dist/googlesheets/sqlExecutor.js +29 -24
  17. package/dist/googlesheets/tableClient.js +14 -5
  18. package/dist/index.d.ts +5 -1
  19. package/dist/index.js +15 -1
  20. package/dist/mssql/engine.js +8 -4
  21. package/dist/mysql/engine.js +1 -1
  22. package/dist/nbase/client.d.ts +92 -0
  23. package/dist/nbase/client.js +147 -0
  24. package/dist/nbase/index.d.ts +47 -0
  25. package/dist/nbase/index.js +80 -0
  26. package/dist/postgres/engine.js +1 -1
  27. package/dist/sqlite/browserEngine.d.ts +27 -0
  28. package/dist/sqlite/browserEngine.js +161 -0
  29. package/dist/sqlite/engine.js +15 -3
  30. package/dist/sqlite/index.d.ts +1 -0
  31. package/dist/sqlite/index.js +1 -0
  32. package/dist/typed.d.ts +5 -0
  33. package/dist/typed.js +2 -0
  34. package/dotnet/An5Adapter.cs +1112 -275
  35. package/dotnet/Base/Core.cs +380 -82
  36. package/dotnet/Mssql/MssqlEngine.cs +145 -144
  37. package/dotnet/Postgres/PostgresEngine.cs +149 -121
  38. package/dotnet/Sqlite/SqliteEngine.cs +214 -0
  39. package/golang/an5_adapter.go +314 -0
  40. package/golang/base/base.go +100 -0
  41. package/golang/base/metadata.go +116 -0
  42. package/golang/base/where.go +311 -0
  43. package/golang/go.mod +3 -0
  44. package/golang/mssql/mssql.go +32 -0
  45. package/golang/postgres/postgres.go +37 -0
  46. package/golang/sqlite/sqlite.go +14 -0
  47. package/golang/sqlite_test.go +42 -0
  48. package/golang/table_client.go +1329 -0
  49. package/package.json +26 -9
  50. package/python/an5_adapter.py +184 -97
  51. package/python/base/__init__.py +3 -3
  52. package/python/base/dialects.py +11 -8
  53. package/python/base/metadata.py +59 -10
  54. package/python/base/sql.py +202 -121
  55. package/python/mssql/__init__.py +1 -1
  56. package/python/mssql/provider.py +62 -62
  57. package/python/postgres/__init__.py +1 -1
  58. package/python/postgres/provider.py +15 -15
  59. package/python/sqlite/__init__.py +3 -0
  60. package/python/sqlite/provider.py +83 -0
  61. package/python/table_client.py +636 -216
  62. package/rust/Cargo.toml +31 -0
  63. package/rust/README.md +71 -0
  64. package/rust/src/adapter.rs +1312 -0
  65. package/rust/src/base/dialect.rs +172 -0
  66. package/rust/src/base/metadata.rs +134 -0
  67. package/rust/src/base/mod.rs +17 -0
  68. package/rust/src/base/where_builder.rs +405 -0
  69. package/rust/src/lib.rs +75 -0
  70. package/typescript/src/an5Adapter.ts +1515 -505
  71. package/typescript/src/base/index.ts +3 -3
  72. package/typescript/src/base/metadata.ts +148 -28
  73. package/typescript/src/base/sql.ts +264 -98
  74. package/typescript/src/base/types.ts +33 -24
  75. package/typescript/src/browser.ts +10 -6
  76. package/typescript/src/config.ts +95 -95
  77. package/typescript/src/googlesheets/adapter.ts +269 -259
  78. package/typescript/src/googlesheets/an5SheetsAdapter.ts +3 -3
  79. package/typescript/src/googlesheets/config.ts +47 -47
  80. package/typescript/src/googlesheets/helpers.ts +144 -143
  81. package/typescript/src/googlesheets/index.ts +3 -3
  82. package/typescript/src/googlesheets/parseConnectionString.ts +48 -46
  83. package/typescript/src/googlesheets/retry.ts +126 -126
  84. package/typescript/src/googlesheets/sqlExecutor.ts +304 -301
  85. package/typescript/src/googlesheets/tableClient.ts +461 -455
  86. package/typescript/src/googlesheets/types.ts +6 -6
  87. package/typescript/src/index.ts +48 -27
  88. package/typescript/src/mssql/engine.ts +134 -132
  89. package/typescript/src/mssql/index.ts +1 -1
  90. package/typescript/src/mysql/engine.ts +80 -80
  91. package/typescript/src/mysql/index.ts +1 -1
  92. package/typescript/src/nbase/client.ts +214 -0
  93. package/typescript/src/nbase/index.ts +104 -0
  94. package/typescript/src/postgres/engine.ts +87 -87
  95. package/typescript/src/postgres/index.ts +1 -1
  96. package/typescript/src/sqlite/browserEngine.ts +192 -0
  97. package/typescript/src/sqlite/engine.ts +66 -55
  98. package/typescript/src/sqlite/index.ts +3 -1
  99. package/typescript/src/typed.ts +52 -0
@@ -1,121 +1,202 @@
1
- from typing import Any, Dict, List, Optional
2
- from .dialects import DIALECT_MSSQL, DIALECT_POSTGRES
3
- from .metadata import model_to_table, model_fields
4
-
5
- # ─── Quoting ───────────────────────────────────────────────────────────────────────
6
-
7
- def _quote(name: str, dialect: str) -> str:
8
- if dialect == DIALECT_POSTGRES:
9
- if name.startswith("[") and name.endswith("]"):
10
- return f'"{name[1:-1]}"'
11
- return f'"{name}"'
12
- return name if name.startswith("[") else f"[{name}]"
13
-
14
-
15
- # ─── Connection helpers live in provider modules ────────────────────────────────
16
-
17
- # ─── Where clause builder ──────────────────────────────────────────────────────────
18
-
19
- def _parse_where(model_name: str, where: Optional[Dict], params: Dict, dialect: str, prefix: str = "") -> str:
20
- if not where:
21
- return ""
22
- conditions: List[str] = []
23
-
24
- for key, value in where.items():
25
- if key == "OR" and isinstance(value, list):
26
- sub = [_parse_where(model_name, v, params, dialect, f"{prefix}or{i}_") for i, v in enumerate(value)]
27
- sub = [s for s in sub if s]
28
- if sub:
29
- conditions.append(f"({' OR '.join(sub)})")
30
- elif key == "AND" and isinstance(value, list):
31
- sub = [_parse_where(model_name, v, params, dialect, f"{prefix}and{i}_") for i, v in enumerate(value)]
32
- sub = [s for s in sub if s]
33
- if sub:
34
- conditions.append(f"({' AND '.join(sub)})")
35
- else:
36
- col = _quote(key, dialect)
37
- pname = f"{prefix}{key}"
38
- placeholder = "%s" if dialect == DIALECT_POSTGRES else "?"
39
- if value is None:
40
- conditions.append(f"{col} IS NULL")
41
- elif isinstance(value, dict):
42
- if "not" in value:
43
- if value["not"] is None:
44
- conditions.append(f"{col} IS NOT NULL")
45
- else:
46
- params[f"{pname}_not"] = value["not"]
47
- conditions.append(f"{col} <> {placeholder}")
48
- if "equals" in value:
49
- params[f"{pname}_eq"] = value["equals"]
50
- conditions.append(f"{col} = {placeholder}")
51
- if "contains" in value:
52
- params[f"{pname}_co"] = f"%{value['contains']}%"
53
- conditions.append(f"{col} LIKE {placeholder}")
54
- if "startsWith" in value:
55
- params[f"{pname}_sw"] = f"{value['startsWith']}%"
56
- conditions.append(f"{col} LIKE {placeholder}")
57
- if "endsWith" in value:
58
- params[f"{pname}_ew"] = f"%{value['endsWith']}"
59
- conditions.append(f"{col} LIKE {placeholder}")
60
- if "gte" in value:
61
- params[f"{pname}_gte"] = value["gte"]
62
- conditions.append(f"{col} >= {placeholder}")
63
- if "lte" in value:
64
- params[f"{pname}_lte"] = value["lte"]
65
- conditions.append(f"{col} <= {placeholder}")
66
- if "gt" in value:
67
- params[f"{pname}_gt"] = value["gt"]
68
- conditions.append(f"{col} > {placeholder}")
69
- if "lt" in value:
70
- params[f"{pname}_lt"] = value["lt"]
71
- conditions.append(f"{col} < {placeholder}")
72
- if "in" in value:
73
- vals = value["in"]
74
- if vals:
75
- placeholders = ", ".join([placeholder] * len(vals))
76
- for i, v in enumerate(vals):
77
- params[f"{pname}_in{i}"] = v
78
- conditions.append(f"{col} IN ({placeholders})")
79
- else:
80
- conditions.append("1=0")
81
- else:
82
- params[pname] = value
83
- conditions.append(f"{col} = {placeholder}")
84
-
85
- return " AND ".join(conditions)
86
-
87
-
88
- def _build_order_by(order_by: Any, dialect: str) -> str:
89
- if not order_by:
90
- return ""
91
- entries = order_by if isinstance(order_by, list) else [order_by]
92
- parts: List[str] = []
93
- for entry in entries:
94
- for col, direction in entry.items():
95
- parts.append(f"{_quote(col, dialect)} {str(direction).upper()}")
96
- return f"ORDER BY {', '.join(parts)}" if parts else ""
97
-
98
-
99
- # ─── Table name resolution ─────────────────────────────────────────────────────────
100
-
101
- def _resolve_table(model_name: str) -> str:
102
- if model_name in model_to_table:
103
- return model_to_table[model_name]
104
- camel = model_name[0].lower() + model_name[1:] if model_name else model_name
105
- if camel in model_to_table:
106
- return model_to_table[camel]
107
- lower = model_name.lower()
108
- if lower in model_to_table:
109
- return model_to_table[lower]
110
- return model_name
111
-
112
-
113
- def _quote_table(t: str, dialect: str) -> str:
114
- q = lambda s: f'"{s}"' if dialect == DIALECT_POSTGRES else f"[{s}]"
115
- if t.startswith("[") or t.startswith('"'):
116
- return t
117
- if "." in t:
118
- return ".".join(q(p) for p in t.split("."))
119
- return q(t)
120
-
121
-
1
+ from typing import Any, Dict, List, Optional
2
+ from .dialects import DIALECT_MSSQL, DIALECT_POSTGRES, DIALECT_SQLITE
3
+ from .metadata import model_to_table, model_fields, resolve_model_key
4
+
5
+ # ─── Quoting ───────────────────────────────────────────────────────────────────────
6
+
7
+ def _strip_wrapping(name: str, left: str, right: str) -> str:
8
+ return name[len(left):-len(right)] if name.startswith(left) and name.endswith(right) else name
9
+
10
+
11
+ def _quote(name: str, dialect: str) -> str:
12
+ raw = str(name)
13
+ if dialect in (DIALECT_POSTGRES, DIALECT_SQLITE):
14
+ unwrapped = _strip_wrapping(_strip_wrapping(raw, "[", "]"), '"', '"')
15
+ return f'"{unwrapped.replace(chr(34), chr(34) * 2)}"'
16
+ unwrapped = _strip_wrapping(raw, "[", "]")
17
+ return f"[{unwrapped.replace(']', ']]')}]"
18
+
19
+
20
+ def _sanitize_param_name(name: str) -> str:
21
+ cleaned = "".join(ch if ch.isalnum() or ch == "_" else "_" for ch in str(name))
22
+ return cleaned if cleaned[:1].isalpha() else f"p_{cleaned}"
23
+
24
+
25
+ def _normalize_sort_direction(direction: Any) -> str:
26
+ return "DESC" if isinstance(direction, str) and direction.upper() == "DESC" else "ASC"
27
+
28
+
29
+ # ─── Connection helpers live in provider modules ────────────────────────────────
30
+
31
+ # ─── Where clause builder ──────────────────────────────────────────────────────────
32
+
33
+ _OPERATOR_KEYS = {"equals", "in", "notIn", "contains", "startsWith", "endsWith", "not", "gte", "lte", "gt", "lt"}
34
+
35
+
36
+ def _is_operator_value(value: Any) -> bool:
37
+ return isinstance(value, dict) and any(op in value for op in _OPERATOR_KEYS)
38
+
39
+
40
+ def _parse_where(model_name: str, where: Optional[Dict], params: Dict, dialect: str, prefix: str = "") -> str:
41
+ if not where:
42
+ return ""
43
+ conditions: List[str] = []
44
+
45
+ clean_where: Dict[str, Any] = {}
46
+ for key, value in where.items():
47
+ if "_" in key and isinstance(value, dict) and not _is_operator_value(value):
48
+ clean_where.update(value)
49
+ else:
50
+ clean_where[key] = value
51
+
52
+ for key, value in clean_where.items():
53
+ if key == "OR" and isinstance(value, list):
54
+ sub = [_parse_where(model_name, v, params, dialect, f"{prefix}or{i}_") for i, v in enumerate(value)]
55
+ sub = [s for s in sub if s]
56
+ if sub:
57
+ conditions.append(f"({' OR '.join(sub)})")
58
+ elif key == "AND":
59
+ items = value if isinstance(value, list) else [value]
60
+ sub = [_parse_where(model_name, v, params, dialect, f"{prefix}and{i}_") for i, v in enumerate(items)]
61
+ sub = [s for s in sub if s]
62
+ if sub:
63
+ conditions.append(f"({' AND '.join(sub)})")
64
+ elif key == "NOT":
65
+ items = value if isinstance(value, list) else [value]
66
+ sub = [_parse_where(model_name, v, params, dialect, f"{prefix}not{i}_") for i, v in enumerate(items)]
67
+ sub = [s for s in sub if s]
68
+ if sub:
69
+ conditions.append(f"NOT ({' AND '.join(sub)})")
70
+ else:
71
+ col = _quote(key, dialect)
72
+ pname = _sanitize_param_name(f"{prefix}{key}")
73
+ placeholder = "%s" if dialect == DIALECT_POSTGRES else "?"
74
+ if value is None:
75
+ conditions.append(f"{col} IS NULL")
76
+ elif isinstance(value, dict):
77
+ if "not" in value:
78
+ if value["not"] is None:
79
+ conditions.append(f"{col} IS NOT NULL")
80
+ elif isinstance(value["not"], dict):
81
+ nested_params: Dict[str, Any] = {}
82
+ nested_sql = _parse_where(model_name, {key: value["not"]}, nested_params, dialect, f"{prefix}{key}_not_")
83
+ params.update(nested_params)
84
+ if nested_sql:
85
+ conditions.append(f"NOT ({nested_sql})")
86
+ else:
87
+ params[f"{pname}_not"] = value["not"]
88
+ conditions.append(f"{col} <> {placeholder}")
89
+ if "equals" in value:
90
+ if value["equals"] is None:
91
+ conditions.append(f"{col} IS NULL")
92
+ else:
93
+ params[f"{pname}_eq"] = value["equals"]
94
+ conditions.append(f"{col} = {placeholder}")
95
+ if "contains" in value:
96
+ params[f"{pname}_co"] = f"%{value['contains']}%"
97
+ conditions.append(f"{col} LIKE {placeholder}")
98
+ if "startsWith" in value:
99
+ params[f"{pname}_sw"] = f"{value['startsWith']}%"
100
+ conditions.append(f"{col} LIKE {placeholder}")
101
+ if "endsWith" in value:
102
+ params[f"{pname}_ew"] = f"%{value['endsWith']}"
103
+ conditions.append(f"{col} LIKE {placeholder}")
104
+ if "gte" in value:
105
+ params[f"{pname}_gte"] = value["gte"]
106
+ conditions.append(f"{col} >= {placeholder}")
107
+ if "lte" in value:
108
+ params[f"{pname}_lte"] = value["lte"]
109
+ conditions.append(f"{col} <= {placeholder}")
110
+ if "gt" in value:
111
+ params[f"{pname}_gt"] = value["gt"]
112
+ conditions.append(f"{col} > {placeholder}")
113
+ if "lt" in value:
114
+ params[f"{pname}_lt"] = value["lt"]
115
+ conditions.append(f"{col} < {placeholder}")
116
+ if "in" in value:
117
+ vals = value["in"]
118
+ if vals:
119
+ placeholders = ", ".join([placeholder] * len(vals))
120
+ for i, v in enumerate(vals):
121
+ params[f"{pname}_in{i}"] = v
122
+ conditions.append(f"{col} IN ({placeholders})")
123
+ else:
124
+ conditions.append("1=0")
125
+ if "notIn" in value:
126
+ vals = value["notIn"]
127
+ if vals:
128
+ placeholders = ", ".join([placeholder] * len(vals))
129
+ for i, v in enumerate(vals):
130
+ params[f"{pname}_notin{i}"] = v
131
+ conditions.append(f"{col} NOT IN ({placeholders})")
132
+ else:
133
+ conditions.append("1=1")
134
+ else:
135
+ params[pname] = value
136
+ conditions.append(f"{col} = {placeholder}")
137
+
138
+ return " AND ".join(conditions)
139
+
140
+
141
+ def _build_order_by(order_by: Any, dialect: str) -> str:
142
+ if not order_by:
143
+ return ""
144
+ entries = order_by if isinstance(order_by, list) else [order_by]
145
+ parts: List[str] = []
146
+ for entry in entries:
147
+ for col, direction in entry.items():
148
+ parts.append(f"{_quote(col, dialect)} {_normalize_sort_direction(direction)}")
149
+ return f"ORDER BY {', '.join(parts)}" if parts else ""
150
+
151
+
152
+ # ─── Table name resolution ─────────────────────────────────────────────────────────
153
+
154
+ def _resolve_table(model_name: str) -> str:
155
+ key = resolve_model_key(model_name)
156
+ if key in model_to_table:
157
+ return model_to_table[key]
158
+ return model_name
159
+
160
+
161
+ def _split_qualified(t: str) -> List[str]:
162
+ """Splits `schema.table` without cutting a dot that sits inside brackets.
163
+
164
+ The generated metadata is always MSSQL-shaped: `[dbo].[users]`. A plain
165
+ `split(".")` yields `['[dbo]', '[users]']` — right for MSSQL, but moving to
166
+ PostgreSQL/SQLite means stripping the old brackets and re-quoting for the
167
+ target dialect, otherwise `[dbo].[users]` stays as-is and the table name
168
+ comes out wrong.
169
+ """
170
+ parts: List[str] = []
171
+ buf: List[str] = []
172
+ open_ch = ""
173
+ close_ch = ""
174
+ for ch in t:
175
+ if open_ch:
176
+ buf.append(ch)
177
+ if ch == close_ch:
178
+ open_ch = ""
179
+ close_ch = ""
180
+ continue
181
+ if ch in ("[", '"'):
182
+ open_ch, close_ch = ch, ("]" if ch == "[" else '"')
183
+ buf.append(ch)
184
+ continue
185
+ if ch == ".":
186
+ parts.append("".join(buf))
187
+ buf = []
188
+ continue
189
+ buf.append(ch)
190
+ parts.append("".join(buf))
191
+ return [p for p in parts if p]
192
+
193
+
194
+ def _quote_table(t: str, dialect: str) -> str:
195
+ # Never return an already bracketed `[...]` name as-is: that quoting is only
196
+ # correct on MSSQL. Other dialects have to strip it and re-quote, or the
197
+ # previous dialect's brackets follow the name into the new dialect.
198
+ parts = _split_qualified(str(t))
199
+ if not parts:
200
+ parts = [str(t)]
201
+ return ".".join(_quote(p, dialect) for p in parts)
202
+
@@ -1 +1 @@
1
- from .provider import PYODBC_AVAILABLE, connect, parse_connection_string, placeholder
1
+ from .provider import PYODBC_AVAILABLE, connect, parse_connection_string, placeholder
@@ -1,62 +1,62 @@
1
- try:
2
- import pyodbc
3
- PYODBC_AVAILABLE = True
4
- except ImportError:
5
- pyodbc = None
6
- PYODBC_AVAILABLE = False
7
-
8
- def parse_connection_string(url: str) -> str:
9
- """Convert sqlserver:// URL to pyodbc connection string."""
10
- url = url.replace("sqlserver://", "")
11
- parts = url.split(";")
12
- host_part = parts[0]
13
- host, _, port = host_part.partition(":")
14
- port = port or "1433"
15
-
16
- config: Dict[str, str] = {"server": host, "port": port}
17
- for part in parts[1:]:
18
- if "=" not in part:
19
- continue
20
- k, _, v = part.partition("=")
21
- k = k.strip().lower()
22
- v = v.strip()
23
- if k in ("database",):
24
- config["database"] = v
25
- elif k in ("user", "uid"):
26
- config["user"] = v
27
- elif k in ("password", "pwd"):
28
- config["password"] = v
29
- elif k == "encrypt":
30
- config["encrypt"] = "yes" if v.lower() == "true" else "no"
31
- elif k == "trustservercertificate":
32
- config["trust"] = "yes" if v.lower() == "true" else "no"
33
-
34
- encrypt = config.get("encrypt", "yes")
35
- trust = config.get("trust", "yes")
36
- if "user" in config:
37
- return (
38
- f"DRIVER={{ODBC Driver 17 for SQL Server}};"
39
- f"SERVER={config['server']},{config['port']};"
40
- f"DATABASE={config.get('database', '')};"
41
- f"UID={config['user']};"
42
- f"PWD={config.get('password', '')};"
43
- f"Encrypt={encrypt};TrustServerCertificate={trust};"
44
- )
45
- # Windows auth
46
- return (
47
- f"DRIVER={{ODBC Driver 17 for SQL Server}};"
48
- f"SERVER={config['server']},{config['port']};"
49
- f"DATABASE={config.get('database', '')};"
50
- f"Trusted_Connection=yes;"
51
- f"Encrypt={encrypt};TrustServerCertificate={trust};"
52
- )
53
-
54
-
55
-
56
- def connect(connection_string: str):
57
- if not PYODBC_AVAILABLE:
58
- raise ImportError("pyodbc is required: pip install pyodbc")
59
- return pyodbc.connect(parse_connection_string(connection_string), autocommit=True)
60
-
61
- def placeholder() -> str:
62
- return "?"
1
+ try:
2
+ import pyodbc
3
+ PYODBC_AVAILABLE = True
4
+ except ImportError:
5
+ pyodbc = None
6
+ PYODBC_AVAILABLE = False
7
+
8
+ def parse_connection_string(url: str) -> str:
9
+ """Convert sqlserver:// URL to pyodbc connection string."""
10
+ url = url.replace("sqlserver://", "")
11
+ parts = url.split(";")
12
+ host_part = parts[0]
13
+ host, _, port = host_part.partition(":")
14
+ port = port or "1433"
15
+
16
+ config: Dict[str, str] = {"server": host, "port": port}
17
+ for part in parts[1:]:
18
+ if "=" not in part:
19
+ continue
20
+ k, _, v = part.partition("=")
21
+ k = k.strip().lower()
22
+ v = v.strip()
23
+ if k in ("database",):
24
+ config["database"] = v
25
+ elif k in ("user", "uid"):
26
+ config["user"] = v
27
+ elif k in ("password", "pwd"):
28
+ config["password"] = v
29
+ elif k == "encrypt":
30
+ config["encrypt"] = "yes" if v.lower() == "true" else "no"
31
+ elif k == "trustservercertificate":
32
+ config["trust"] = "yes" if v.lower() == "true" else "no"
33
+
34
+ encrypt = config.get("encrypt", "yes")
35
+ trust = config.get("trust", "yes")
36
+ if "user" in config:
37
+ return (
38
+ f"DRIVER={{ODBC Driver 17 for SQL Server}};"
39
+ f"SERVER={config['server']},{config['port']};"
40
+ f"DATABASE={config.get('database', '')};"
41
+ f"UID={config['user']};"
42
+ f"PWD={config.get('password', '')};"
43
+ f"Encrypt={encrypt};TrustServerCertificate={trust};"
44
+ )
45
+ # Windows auth
46
+ return (
47
+ f"DRIVER={{ODBC Driver 17 for SQL Server}};"
48
+ f"SERVER={config['server']},{config['port']};"
49
+ f"DATABASE={config.get('database', '')};"
50
+ f"Trusted_Connection=yes;"
51
+ f"Encrypt={encrypt};TrustServerCertificate={trust};"
52
+ )
53
+
54
+
55
+
56
+ def connect(connection_string: str):
57
+ if not PYODBC_AVAILABLE:
58
+ raise ImportError("pyodbc is required: pip install pyodbc")
59
+ return pyodbc.connect(parse_connection_string(connection_string), autocommit=True)
60
+
61
+ def placeholder() -> str:
62
+ return "?"
@@ -1 +1 @@
1
- from .provider import PSYCOPG2_AVAILABLE, connect, placeholder
1
+ from .provider import PSYCOPG2_AVAILABLE, connect, placeholder
@@ -1,15 +1,15 @@
1
- try:
2
- import psycopg2
3
- import psycopg2.extras
4
- PSYCOPG2_AVAILABLE = True
5
- except ImportError:
6
- psycopg2 = None
7
- PSYCOPG2_AVAILABLE = False
8
-
9
- def connect(connection_string: str):
10
- if not PSYCOPG2_AVAILABLE:
11
- raise ImportError("psycopg2 is required: pip install psycopg2-binary")
12
- return psycopg2.connect(connection_string)
13
-
14
- def placeholder() -> str:
15
- return "%s"
1
+ try:
2
+ import psycopg2
3
+ import psycopg2.extras
4
+ PSYCOPG2_AVAILABLE = True
5
+ except ImportError:
6
+ psycopg2 = None
7
+ PSYCOPG2_AVAILABLE = False
8
+
9
+ def connect(connection_string: str):
10
+ if not PSYCOPG2_AVAILABLE:
11
+ raise ImportError("psycopg2 is required: pip install psycopg2-binary")
12
+ return psycopg2.connect(connection_string)
13
+
14
+ def placeholder() -> str:
15
+ return "%s"
@@ -0,0 +1,3 @@
1
+ from .provider import connect, is_memory, parse_connection_string, placeholder
2
+
3
+ __all__ = ["connect", "is_memory", "parse_connection_string", "placeholder"]
@@ -0,0 +1,83 @@
1
+ """SQLite provider — uses the standard library `sqlite3`, no extra driver needed.
2
+
3
+ Why it exists: `base/dialects.py` already had `DIALECT_SQLITE` and the SQL layer
4
+ already used the `?` placeholder for it, but with no provider `An5Adapter._connect`
5
+ fell back to MSSQL — every statement then carried `WITH (NOLOCK)` and
6
+ `OFFSET ... FETCH NEXT`, SQL SQLite does not understand, failing on the first one.
7
+
8
+ Why `isolation_level=None`: `sqlite3` opens an implicit transaction before each DML
9
+ statement by default, so `An5Adapter.exec` (which opens/closes a connection per
10
+ statement) would leave transactions dangling. `None` means autocommit, matching the
11
+ `pyodbc.connect(..., autocommit=True)` of the MSSQL provider.
12
+ """
13
+
14
+ import sqlite3
15
+ from typing import Tuple
16
+
17
+ # Strip the `sqlite:`/`sqlite://`/`sqlite:///` wrapper first, only then look for
18
+ # `file:`. Two steps are required because `sqlite:file::memory:?cache=shared` only
19
+ # becomes a URI after `sqlite:` is removed; checking once would miss it and sqlite3
20
+ # would treat the whole thing as a file name.
21
+ _SQLITE_PREFIXES = ("sqlite:///", "sqlite://", "sqlite:")
22
+ _FILE_PREFIX = "file:"
23
+
24
+
25
+ def parse_connection_string(url: str) -> Tuple[str, bool]:
26
+ """Returns (path/URI, whether sqlite3 should treat it as a URI).
27
+
28
+ Accepts `sqlite:///path`, `sqlite:path`, `file:path` and a bare path.
29
+ `file:` has to return URI=True because sqlite3 only understands URIs with the
30
+ `uri` flag on.
31
+ """
32
+ value = (url or "").strip()
33
+ for prefix in _SQLITE_PREFIXES:
34
+ if value.lower().startswith(prefix):
35
+ value = value[len(prefix):]
36
+ break
37
+ is_uri = value.lower().startswith(_FILE_PREFIX)
38
+ if is_uri:
39
+ # Keep the `file:` prefix — sqlite3 needs the full URI form
40
+ # (`file::memory:?cache=shared`); without it `:memory:?cache=shared` is
41
+ # no longer a valid URI.
42
+ return value, True
43
+ # On Windows `sqlite:///C:/path/x.db` is left with `/C:/path/x.db`; the
44
+ # leading slash belongs to the URL, not to the path.
45
+ if len(value) > 2 and value[0] == "/" and value[2] == ":":
46
+ value = value[1:]
47
+ return value or ":memory:", is_uri
48
+
49
+
50
+ def is_memory(url: str) -> bool:
51
+ """An in-memory database does not survive every connection being closed.
52
+
53
+ The adapter opens/closes a connection per statement, so with a plain
54
+ `:memory:` the data disappears after the first statement (the table just
55
+ CREATEd is gone). The adapter therefore has to hold a long-lived connection
56
+ for this case; see `An5Adapter._acquire`.
57
+ """
58
+ target, is_uri = parse_connection_string(url)
59
+ if is_uri:
60
+ # `parse_connection_string` returns the whole URI (with `file:`).
61
+ inner = target[len(_FILE_PREFIX):] if target.lower().startswith(_FILE_PREFIX) else target
62
+ return inner.startswith(":memory:") or "mode=memory" in inner
63
+ return target == ":memory:"
64
+
65
+ def connect(connection_string: str):
66
+ target, is_uri = parse_connection_string(connection_string)
67
+ conn = sqlite3.connect(
68
+ target,
69
+ isolation_level=None,
70
+ check_same_thread=False,
71
+ uri=is_uri,
72
+ )
73
+ # There is no `NOLOCK`; WAL lets concurrent readers and writers proceed
74
+ # without blocking each other, and foreign_keys is enabled because SQLite
75
+ # defaults it OFF (unlike every other dialect).
76
+ conn.execute("PRAGMA foreign_keys = ON")
77
+ if not is_memory(connection_string) and not is_uri:
78
+ conn.execute("PRAGMA journal_mode = WAL")
79
+ return conn
80
+
81
+
82
+ def placeholder() -> str:
83
+ return "?"