@an5/adapters 0.2.6 → 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.
@@ -0,0 +1,214 @@
1
+ using System;
2
+ using System.Collections.Generic;
3
+ using System.Data;
4
+ using System.Reflection;
5
+
6
+ namespace An5Orm
7
+ {
8
+ // ─── SQLite Engine ──────────────────────────────────────────────────────────
9
+ //
10
+ // Microsoft.Data.Sqlite is a different type family from the other two
11
+ // providers (SqliteConnection/SqliteCommand, not DbConnection subclasses you
12
+ // can reuse), so it gets its own engine rather than a shared abstraction.
13
+ //
14
+ // Notable differences from MSSQL, all handled by the SQL builder in
15
+ // An5Adapter rather than here: no WITH (NOLOCK) table hint, no SELECT TOP,
16
+ // LIMIT/OFFSET instead of OFFSET/FETCH NEXT, and no stored procedures.
17
+
18
+ internal class SqliteEngine : IQueryEngine
19
+ {
20
+ public Dialect Dialect => Dialect.Sqlite;
21
+ private readonly string _connectionString;
22
+ private readonly int _commandTimeout;
23
+
24
+ public SqliteEngine(string connectionString, int commandTimeout)
25
+ {
26
+ _connectionString = SqliteConnectionString.Normalize(connectionString);
27
+ _commandTimeout = commandTimeout;
28
+ }
29
+
30
+ // Set while a transaction is open; statements then join it instead of
31
+ // opening a second connection, which SQLite would refuse.
32
+ private Microsoft.Data.Sqlite.SqliteConnection _txnConn;
33
+
34
+ private Microsoft.Data.Sqlite.SqliteConnection OpenNewConnection()
35
+ {
36
+ var conn = new Microsoft.Data.Sqlite.SqliteConnection(_connectionString);
37
+ conn.Open();
38
+ // WAL keeps readers from blocking the writer, and foreign keys are
39
+ // off by default in SQLite even when the schema declares them.
40
+ using var pragma = conn.CreateCommand();
41
+ pragma.CommandText = "PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;";
42
+ pragma.ExecuteNonQuery();
43
+ return conn;
44
+ }
45
+
46
+ private ConnectionScope<Microsoft.Data.Sqlite.SqliteConnection> Acquire()
47
+ => _txnConn != null
48
+ ? new ConnectionScope<Microsoft.Data.Sqlite.SqliteConnection>(_txnConn, owned: false)
49
+ : new ConnectionScope<Microsoft.Data.Sqlite.SqliteConnection>(OpenNewConnection(), owned: true);
50
+
51
+ private Microsoft.Data.Sqlite.SqliteCommand BuildCommand(
52
+ Microsoft.Data.Sqlite.SqliteConnection conn, string sql, Dictionary<string, object> parameters)
53
+ {
54
+ var cmd = conn.CreateCommand();
55
+ cmd.CommandText = sql;
56
+ cmd.CommandTimeout = _commandTimeout;
57
+ if (parameters != null)
58
+ {
59
+ foreach (var kv in parameters)
60
+ {
61
+ // SqliteParameter names must not carry the prefix; the SQL
62
+ // keeps @name and the parameter is registered as name.
63
+ var paramName = kv.Key.TrimStart('@');
64
+ var value = kv.Value ?? DBNull.Value;
65
+
66
+ var param = cmd.CreateParameter();
67
+ param.ParameterName = paramName;
68
+ // A JSON vector or a bool needs a type SQLite will store in a
69
+ // column rather than coerce to an integer.
70
+ if (value is bool flag) param.Value = flag ? 1 : 0;
71
+ else param.Value = value;
72
+ cmd.Parameters.Add(param);
73
+ }
74
+ }
75
+ return cmd;
76
+ }
77
+
78
+ public List<Dictionary<string, object>> QueryRaw(string sql, Dictionary<string, object> parameters)
79
+ {
80
+ var results = new List<Dictionary<string, object>>();
81
+ using var scope = Acquire();
82
+ using var cmd = BuildCommand(scope.Connection, sql, parameters);
83
+ using var reader = cmd.ExecuteReader();
84
+ while (reader.Read())
85
+ {
86
+ var row = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
87
+ for (int i = 0; i < reader.FieldCount; i++)
88
+ {
89
+ row[reader.GetName(i)] = reader.IsDBNull(i) ? null : reader.GetValue(i);
90
+ }
91
+ results.Add(row);
92
+ }
93
+ return results;
94
+ }
95
+
96
+ public List<T> QueryRaw<T>(string sql, Dictionary<string, object> parameters) where T : new()
97
+ {
98
+ var results = new List<T>();
99
+ using var scope = Acquire();
100
+ using var cmd = BuildCommand(scope.Connection, sql, parameters);
101
+ using var reader = cmd.ExecuteReader();
102
+ var props = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
103
+ while (reader.Read())
104
+ {
105
+ var item = new T();
106
+ foreach (var prop in props)
107
+ {
108
+ if (!HasColumn(reader, prop.Name)) continue;
109
+ var val = reader[prop.Name];
110
+ if (val == DBNull.Value) continue;
111
+ try { prop.SetValue(item, Convert.ChangeType(val, prop.PropertyType)); } catch { }
112
+ }
113
+ results.Add(item);
114
+ }
115
+ return results;
116
+ }
117
+
118
+ public int ExecuteRaw(string sql, Dictionary<string, object> parameters)
119
+ {
120
+ using var scope = Acquire();
121
+ using var cmd = BuildCommand(scope.Connection, sql, parameters);
122
+ return cmd.ExecuteNonQuery();
123
+ }
124
+
125
+ private static bool HasColumn(Microsoft.Data.Sqlite.SqliteDataReader reader, string name)
126
+ {
127
+ for (int i = 0; i < reader.FieldCount; i++)
128
+ if (reader.GetName(i).Equals(name, StringComparison.OrdinalIgnoreCase)) return true;
129
+ return false;
130
+ }
131
+
132
+ /// <summary>Detaches the transaction connection, so later statements open their own.</summary>
133
+ internal void ReleaseTransactionConnection() => _txnConn = null;
134
+
135
+ public An5TransactionBase BeginTransaction()
136
+ {
137
+ if (_txnConn != null)
138
+ throw new InvalidOperationException("A transaction is already open on this adapter.");
139
+ var conn = OpenNewConnection();
140
+ var tx = conn.BeginTransaction();
141
+ _txnConn = conn;
142
+ return new SqliteTransaction(this, conn, tx);
143
+ }
144
+ }
145
+
146
+ internal class SqliteTransaction : An5TransactionBase
147
+ {
148
+ private readonly SqliteEngine _engine;
149
+ private readonly Microsoft.Data.Sqlite.SqliteConnection _conn;
150
+ private readonly Microsoft.Data.Sqlite.SqliteTransaction _tx;
151
+
152
+ public SqliteTransaction(SqliteEngine engine, Microsoft.Data.Sqlite.SqliteConnection conn, Microsoft.Data.Sqlite.SqliteTransaction tx)
153
+ {
154
+ _engine = engine;
155
+ _conn = conn;
156
+ _tx = tx;
157
+ }
158
+
159
+ public override void Commit() { _tx.Commit(); }
160
+
161
+ public override void Rollback() { _tx.Rollback(); }
162
+
163
+ public override void Dispose()
164
+ {
165
+ _engine.ReleaseTransactionConnection();
166
+ _tx.Dispose();
167
+ _conn.Dispose();
168
+ }
169
+ }
170
+
171
+ // ─── Connection string normalisation ──────────────────────────────────────
172
+
173
+ internal static class SqliteConnectionString
174
+ {
175
+ /// <summary>
176
+ /// Turns the accepted spellings into a Data Source string.
177
+ ///
178
+ /// Microsoft.Data.Sqlite only understands `Data Source=`, so the bare
179
+ /// paths and `sqlite:` URLs the rest of the ORM accepts have to be
180
+ /// rewritten. An in-memory database has to stay open for the life of the
181
+ /// connection, which is why callers keep one connection per operation
182
+ /// open only for the duration of a statement.
183
+ /// </summary>
184
+ public static string Normalize(string connectionString)
185
+ {
186
+ var cs = (connectionString ?? "").Trim();
187
+ if (cs.Length == 0) return "Data Source=:memory:";
188
+
189
+ // Already a Microsoft.Data.Sqlite connection string.
190
+ if (cs.IndexOf("Data Source", StringComparison.OrdinalIgnoreCase) >= 0
191
+ || cs.IndexOf("DataSource", StringComparison.OrdinalIgnoreCase) >= 0
192
+ || cs.IndexOf("Mode", StringComparison.OrdinalIgnoreCase) >= 0)
193
+ return cs;
194
+
195
+ if (cs == ":memory:")
196
+ return "Data Source=:memory:";
197
+
198
+ if (cs.StartsWith("sqlite://", StringComparison.OrdinalIgnoreCase))
199
+ return "Data Source=" + cs.Substring("sqlite://".Length);
200
+
201
+ if (cs.StartsWith("sqlite:", StringComparison.OrdinalIgnoreCase))
202
+ {
203
+ // `sqlite:app.db` and `sqlite::memory:`
204
+ return "Data Source=" + cs.Substring("sqlite:".Length);
205
+ }
206
+
207
+ if (cs.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
208
+ return cs; // SQLite URI, understood as-is.
209
+
210
+ // A bare path.
211
+ return "Data Source=" + cs;
212
+ }
213
+ }
214
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@an5/adapters",
3
- "version": "0.2.6",
4
- "description": "Standalone language adapters for AN5 ORM - TypeScript, Python, .NET, Go, and Google Sheets query execution.",
3
+ "version": "0.2.9",
4
+ "description": "Standalone language adapters for AN5 ORM - TypeScript, Python, .NET, Go, Rust, and Google Sheets query execution.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -21,7 +21,9 @@
21
21
  "./typescript/*": "./dist/*",
22
22
  "./python": "./python/an5_adapter.py",
23
23
  "./dotnet": "./dotnet/An5Adapter.cs",
24
- "./golang": "./golang/an5_adapter.go"
24
+ "./golang": "./golang/an5_adapter.go",
25
+ "./rust": "./rust/Cargo.toml",
26
+ "./nbase": "./dist/nbase/index.js"
25
27
  },
26
28
  "author": "an5ORM",
27
29
  "homepage": "https://github.com/an5ORM/an5",
@@ -37,7 +39,8 @@
37
39
  "python",
38
40
  "dotnet",
39
41
  "go",
40
- "golang"
42
+ "golang",
43
+ "rust"
41
44
  ],
42
45
  "files": [
43
46
  "dist/**/*.{js,d.ts}",
@@ -45,19 +48,23 @@
45
48
  "python/**/*.py",
46
49
  "dotnet/**/*",
47
50
  "golang/**/*",
48
- "README.md"
51
+ "rust/Cargo.toml",
52
+ "rust/src/**/*",
53
+ "README.md",
54
+ "typescript/src/nbase/**/*.ts"
49
55
  ],
50
56
  "scripts": {
51
57
  "build": "tsc -p tsconfig.json",
52
58
  "pretest": "npm run build",
53
59
  "prepack": "npm run build",
54
60
  "build:python": "(python -m build --outdir dist-py || python3 -m build --outdir dist-py)",
55
- "test": "node test/unit.test.js && node test/package.test.js",
61
+ "test": "node test/unit.test.js && node test/package.test.js && node test/nbase.test.js && node --test test/version-sync.test.js",
56
62
  "test:package:smoke": "node scripts/package-smoke.js",
57
63
  "test:integration:live": "node test/live-db.integration.test.js",
58
64
  "test:python": "(python -m compileall python 2>&1 || python3 -m compileall python 2>&1)",
59
- "test:dotnet": "node scripts/dotnet-compile-check.js",
60
- "test:go": "cd golang && go test ./..."
65
+ "test:dotnet": "node scripts/dotnet-compile-check.js && node scripts/dotnet-sqlite-smoke.js",
66
+ "test:go": "cd golang && go test ./...",
67
+ "test:rust": "node scripts/rust-compile-check.js"
61
68
  },
62
69
  "dependencies": {
63
70
  "googleapis": "^134.0.0",
@@ -3,14 +3,16 @@
3
3
  from typing import Dict, List, Optional, Any
4
4
 
5
5
  try:
6
- from .base import DIALECT_MSSQL, DIALECT_POSTGRES, detect_dialect, set_adapter_metadata
6
+ from .base import DIALECT_MSSQL, DIALECT_POSTGRES, DIALECT_SQLITE, detect_dialect, set_adapter_metadata
7
7
  from .mssql import connect as connect_mssql
8
8
  from .postgres import connect as connect_postgres
9
+ from .sqlite import connect as connect_sqlite, is_memory as is_sqlite_memory
9
10
  from .table_client import AdapterTableClient, ViewClient
10
11
  except ImportError:
11
- from base import DIALECT_MSSQL, DIALECT_POSTGRES, detect_dialect, set_adapter_metadata
12
+ from base import DIALECT_MSSQL, DIALECT_POSTGRES, DIALECT_SQLITE, detect_dialect, set_adapter_metadata
12
13
  from mssql import connect as connect_mssql
13
14
  from postgres import connect as connect_postgres
15
+ from sqlite import connect as connect_sqlite, is_memory as is_sqlite_memory
14
16
  from table_client import AdapterTableClient, ViewClient
15
17
 
16
18
  # Backward-compatible aliases used by tests and older imports.
@@ -24,12 +26,38 @@ class An5Adapter:
24
26
  def __init__(self, connection_string: str):
25
27
  self._dialect = detect_dialect(connection_string)
26
28
  self._conn_str = connection_string
29
+ # The open connection of `transaction()`, when there is one. Every
30
+ # statement in the callback has to use this very connection, otherwise a
31
+ # rollback returns to a different, already committed connection — that
32
+ # is "the error was raised but the data is still there".
33
+ self._txn_conn = None
34
+ # Long-lived connection for in-memory databases, because `:memory:` is
35
+ # gone as soon as every connection is closed.
36
+ self._conn = None
27
37
 
28
38
  def _connect(self):
29
39
  if self._dialect == DIALECT_POSTGRES:
30
40
  return connect_postgres(self._conn_str)
41
+ if self._dialect == DIALECT_SQLITE:
42
+ return connect_sqlite(self._conn_str)
31
43
  return connect_mssql(self._conn_str)
32
44
 
45
+ def _acquire(self):
46
+ """Returns (connection, whether we opened it and must close it)."""
47
+ if self._txn_conn is not None:
48
+ return self._txn_conn, False
49
+ if self._dialect == DIALECT_SQLITE and is_sqlite_memory(self._conn_str):
50
+ if self._conn is None:
51
+ self._conn = self._connect()
52
+ return self._conn, False
53
+ return self._connect(), True
54
+
55
+ def close(self):
56
+ """Closes the long-lived connection (in-memory databases only)."""
57
+ if self._conn is not None:
58
+ self._conn.close()
59
+ self._conn = None
60
+
33
61
  def _to_dicts(self, cursor, query: str) -> List[Dict]:
34
62
  if cursor.description:
35
63
  cols = [col[0] for col in cursor.description]
@@ -38,22 +66,24 @@ class An5Adapter:
38
66
  return []
39
67
 
40
68
  def exec(self, query: str, params: Optional[List] = None) -> List[Dict]:
41
- conn = self._connect()
69
+ conn, owned = self._acquire()
42
70
  try:
43
71
  cursor = conn.cursor()
44
72
  cursor.execute(query, params or [])
45
73
  return self._to_dicts(cursor, query)
46
74
  finally:
47
- conn.close()
75
+ if owned:
76
+ conn.close()
48
77
 
49
78
  def execute(self, query: str, params: Optional[List] = None) -> int:
50
- conn = self._connect()
79
+ conn, owned = self._acquire()
51
80
  try:
52
81
  cursor = conn.cursor()
53
82
  cursor.execute(query, params or [])
54
83
  return cursor.rowcount
55
84
  finally:
56
- conn.close()
85
+ if owned:
86
+ conn.close()
57
87
 
58
88
  def query_raw(self, query: str, *values) -> List[Dict]:
59
89
  return self.exec(query, list(values))
@@ -68,6 +98,15 @@ class An5Adapter:
68
98
  return ViewClient(self, view_name)
69
99
 
70
100
  def query_proc(self, proc_name: str, params: Optional[List] = None) -> List[Dict]:
101
+ if self._dialect == DIALECT_SQLITE:
102
+ # SQLite has no stored procedures. This used to fall through to the
103
+ # MSSQL branch and generate `EXEC ...`, which is not valid SQL, and
104
+ # the error only surfaced at the call. Saying so beats sending wrong
105
+ # SQL.
106
+ raise NotImplementedError(
107
+ "SQLite has no stored procedures. Use the generated table client "
108
+ "(db.<model>.*) or db.exec() with a parameterised query."
109
+ )
71
110
  if self._dialect == DIALECT_POSTGRES:
72
111
  placeholders = ", ".join(["%s"] * len(params or []))
73
112
  sql = f"CALL {proc_name}({placeholders})" if placeholders else f"CALL {proc_name}()"
@@ -77,6 +116,11 @@ class An5Adapter:
77
116
  return self.exec(sql, params or [])
78
117
 
79
118
  def execute_proc(self, proc_name: str, params: Optional[List] = None) -> int:
119
+ if self._dialect == DIALECT_SQLITE:
120
+ raise NotImplementedError(
121
+ "SQLite has no stored procedures. Use the generated table client "
122
+ "(db.<model>.*) or db.execute() with a parameterised query."
123
+ )
80
124
  if self._dialect == DIALECT_POSTGRES:
81
125
  placeholders = ", ".join(["%s"] * len(params or []))
82
126
  sql = f"CALL {proc_name}({placeholders})" if placeholders else f"CALL {proc_name}()"
@@ -89,8 +133,24 @@ class An5Adapter:
89
133
  return self.table(model_name)
90
134
 
91
135
  def transaction(self, fn):
92
- conn = self._connect()
93
- conn.autocommit = False
136
+ if self._txn_conn is not None:
137
+ raise RuntimeError("Nested transaction() is not supported")
138
+ # In-memory database: the connection holding the data has to be reused.
139
+ # Opening a new one here points at a different, empty database — the
140
+ # table just created disappears and every statement in the transaction
141
+ # fails.
142
+ conn = self._conn if self._conn is not None else self._connect()
143
+ owned = self._conn is None
144
+ if self._dialect == DIALECT_SQLITE:
145
+ # `sqlite3.Connection` has no `autocommit` attribute; transactions
146
+ # are driven by `isolation_level`. The provider opens at `None`
147
+ # (autocommit), so to get a transaction it has to be set back to `""`
148
+ # and sqlite3 opens an implicit BEGIN before each DML statement —
149
+ # only then do `commit()`/`rollback()` do anything.
150
+ conn.isolation_level = ""
151
+ else:
152
+ conn.autocommit = False
153
+ self._txn_conn = conn
94
154
  try:
95
155
  result = fn(self)
96
156
  conn.commit()
@@ -99,9 +159,13 @@ class An5Adapter:
99
159
  conn.rollback()
100
160
  raise
101
161
  finally:
162
+ self._txn_conn = None
102
163
  if self._dialect == DIALECT_POSTGRES:
103
164
  conn.autocommit = True
104
- conn.close()
165
+ elif self._dialect == DIALECT_SQLITE:
166
+ conn.isolation_level = None
167
+ if owned:
168
+ conn.close()
105
169
 
106
170
  def create_an5_adapter(connection_string: str) -> An5Adapter:
107
171
  return An5Adapter(connection_string)
@@ -113,6 +177,7 @@ __all__ = [
113
177
  "create_an5_adapter",
114
178
  "DIALECT_MSSQL",
115
179
  "DIALECT_POSTGRES",
180
+ "DIALECT_SQLITE",
116
181
  "_detect_dialect",
117
182
  "_parse_connection_string",
118
183
  "set_adapter_metadata",
@@ -1,3 +1,3 @@
1
- from .dialects import DIALECT_MSSQL, DIALECT_POSTGRES, detect_dialect
1
+ from .dialects import DIALECT_MSSQL, DIALECT_POSTGRES, DIALECT_SQLITE, detect_dialect
2
2
  from .metadata import model_to_table, model_fields, relation_map, set_adapter_metadata, get_relations_for_model, get_model_to_table, get_fields_for_model
3
3
  from .sql import _quote, _parse_where, _build_order_by, _resolve_table, _quote_table
@@ -25,11 +25,35 @@ def set_adapter_metadata(metadata: Dict[str, Any]) -> None:
25
25
  "localKey": defn.get("localKey") or defn.get("local_key") or "",
26
26
  }
27
27
 
28
+ def resolve_model_key(model_name: str) -> str:
29
+ """The model name in the metadata, accepting a different capitalisation.
30
+
31
+ The generated client registers tables under their PascalCase name
32
+ (`CatalogType`) while the metadata keys them camelCase/snake_case
33
+ (`catalogType`, `catalog_type`). A plain lookup would find no columns at all
34
+ and silently skip `isId` — that is, never generate the primary key.
35
+ """
36
+ if not model_name:
37
+ return model_name
38
+ if model_name in model_to_table or model_name in model_fields:
39
+ return model_name
40
+ camel = model_name[0].lower() + model_name[1:]
41
+ if camel in model_to_table or camel in model_fields:
42
+ return camel
43
+ lower = model_name.lower()
44
+ if lower in model_to_table or lower in model_fields:
45
+ return lower
46
+ return model_name
47
+
48
+
28
49
  def get_relations_for_model(model_name: str) -> Dict[str, Dict[str, str]]:
29
- return relation_map.get(model_name) or {}
50
+ return relation_map.get(model_name) or relation_map.get(resolve_model_key(model_name)) or {}
30
51
 
31
52
  def get_model_to_table() -> Dict[str, str]:
32
53
  return dict(model_to_table)
33
54
 
34
55
  def get_fields_for_model(model_name: str) -> Any:
56
+ key = resolve_model_key(model_name)
57
+ if key in model_fields:
58
+ return model_fields[key]
35
59
  return model_fields.get(model_name)
@@ -1,6 +1,6 @@
1
1
  from typing import Any, Dict, List, Optional
2
2
  from .dialects import DIALECT_MSSQL, DIALECT_POSTGRES, DIALECT_SQLITE
3
- from .metadata import model_to_table, model_fields
3
+ from .metadata import model_to_table, model_fields, resolve_model_key
4
4
 
5
5
  # ─── Quoting ───────────────────────────────────────────────────────────────────────
6
6
 
@@ -152,21 +152,51 @@ def _build_order_by(order_by: Any, dialect: str) -> str:
152
152
  # ─── Table name resolution ─────────────────────────────────────────────────────────
153
153
 
154
154
  def _resolve_table(model_name: str) -> str:
155
- if model_name in model_to_table:
156
- return model_to_table[model_name]
157
- camel = model_name[0].lower() + model_name[1:] if model_name else model_name
158
- if camel in model_to_table:
159
- return model_to_table[camel]
160
- lower = model_name.lower()
161
- if lower in model_to_table:
162
- return model_to_table[lower]
155
+ key = resolve_model_key(model_name)
156
+ if key in model_to_table:
157
+ return model_to_table[key]
163
158
  return model_name
164
159
 
165
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
+
166
194
  def _quote_table(t: str, dialect: str) -> str:
167
- if t.startswith("[") or t.startswith('"'):
168
- return t
169
- if "." in t:
170
- return ".".join(_quote(p, dialect) for p in t.split("."))
171
- return _quote(t, dialect)
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)
172
202
 
@@ -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 "?"