@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.
- package/README.md +96 -2
- package/dist/an5Adapter.d.ts +57 -0
- package/dist/an5Adapter.js +262 -4
- package/dist/base/types.d.ts +6 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +9 -1
- package/dist/nbase/client.d.ts +92 -0
- package/dist/nbase/client.js +147 -0
- package/dist/nbase/index.d.ts +47 -0
- package/dist/nbase/index.js +80 -0
- package/dotnet/An5Adapter.cs +41 -18
- package/dotnet/Base/Core.cs +42 -2
- package/dotnet/Postgres/PostgresEngine.cs +58 -30
- package/dotnet/Sqlite/SqliteEngine.cs +214 -0
- package/package.json +15 -8
- package/python/an5_adapter.py +74 -9
- package/python/base/__init__.py +1 -1
- package/python/base/metadata.py +25 -1
- package/python/base/sql.py +44 -14
- package/python/sqlite/__init__.py +3 -0
- package/python/sqlite/provider.py +83 -0
- package/python/table_client.py +53 -36
- package/rust/Cargo.toml +31 -0
- package/rust/README.md +71 -0
- package/rust/src/adapter.rs +1312 -0
- package/rust/src/base/dialect.rs +172 -0
- package/rust/src/base/metadata.rs +134 -0
- package/rust/src/base/mod.rs +17 -0
- package/rust/src/base/where_builder.rs +405 -0
- package/rust/src/lib.rs +75 -0
- package/typescript/src/an5Adapter.ts +299 -4
- package/typescript/src/base/types.ts +6 -0
- package/typescript/src/index.ts +17 -0
- package/typescript/src/nbase/client.ts +214 -0
- package/typescript/src/nbase/index.ts +104 -0
|
@@ -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.
|
|
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
|
-
"
|
|
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",
|
package/python/an5_adapter.py
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
75
|
+
if owned:
|
|
76
|
+
conn.close()
|
|
48
77
|
|
|
49
78
|
def execute(self, query: str, params: Optional[List] = None) -> int:
|
|
50
|
-
conn = self.
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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",
|
package/python/base/__init__.py
CHANGED
|
@@ -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
|
package/python/base/metadata.py
CHANGED
|
@@ -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)
|
package/python/base/sql.py
CHANGED
|
@@ -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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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,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 "?"
|