@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.
@@ -4,9 +4,9 @@ import json
4
4
  import uuid
5
5
  from typing import Any, Dict, Generic, List, Optional, TypeVar
6
6
  try:
7
- from .base import DIALECT_MSSQL, DIALECT_POSTGRES, model_fields, _build_order_by, _parse_where, _quote, _quote_table, _resolve_table, get_relations_for_model
7
+ from .base import DIALECT_MSSQL, DIALECT_POSTGRES, DIALECT_SQLITE, get_fields_for_model, _build_order_by, _parse_where, _quote, _quote_table, _resolve_table, get_relations_for_model
8
8
  except ImportError:
9
- from base import DIALECT_MSSQL, DIALECT_POSTGRES, model_fields, _build_order_by, _parse_where, _quote, _quote_table, _resolve_table, get_relations_for_model
9
+ from base import DIALECT_MSSQL, DIALECT_POSTGRES, DIALECT_SQLITE, get_fields_for_model, _build_order_by, _parse_where, _quote, _quote_table, _resolve_table, get_relations_for_model
10
10
 
11
11
  # ─── Select / Include helpers ────────────────────────────────────────────────────────
12
12
 
@@ -179,7 +179,11 @@ def _to_non_negative_int(value: Any, fallback: int = 0) -> int:
179
179
 
180
180
  def _append_update_set(set_parts: List[str], values: List[Any], col: str, val: Any, dialect: str) -> None:
181
181
  quoted = _quote(col, dialect)
182
- placeholder = "?" if dialect == DIALECT_MSSQL else "%s"
182
+ # Only PostgreSQL uses `%s`; MSSQL and SQLite both use `?`. The condition is
183
+ # written as "is it Postgres" rather than "is it MSSQL" so a new dialect cannot
184
+ # fall into the `%s` branch by default — which once made SQLite generate SQL it
185
+ # could not parse.
186
+ placeholder = "%s" if dialect == DIALECT_POSTGRES else "?"
183
187
  if isinstance(val, dict):
184
188
  if "increment" in val:
185
189
  set_parts.append(f"{quoted} = {quoted} + {placeholder}")
@@ -232,22 +236,31 @@ class AdapterTableClient(Generic[T]):
232
236
 
233
237
  @property
234
238
  def _nolock(self) -> str:
235
- return "" if self._dialect == DIALECT_POSTGRES else " WITH (NOLOCK)"
239
+ # `WITH (NOLOCK)` is an MSSQL table hint. PostgreSQL has none and SQLite
240
+ # has no equivalent concept at all — leaving it in makes every generated
241
+ # SELECT invalid SQL, failing on the first one.
242
+ return "" if self._dialect in (DIALECT_POSTGRES, DIALECT_SQLITE) else " WITH (NOLOCK)"
236
243
 
237
244
  @property
238
245
  def _fields(self) -> List[Dict]:
239
- fields = model_fields.get(self._model, [])
246
+ # Uses the metadata resolver instead of a plain lookup: the generated
247
+ # client names tables in PascalCase while the metadata keys them in
248
+ # camelCase, so a plain lookup yields an empty list and silently skips
249
+ # `isId` (never generating the primary key).
250
+ fields = get_fields_for_model(self._model)
240
251
  if isinstance(fields, dict):
241
252
  raise TypeError(
242
253
  "AN5 Python metadata is out of date. Regenerate with @an5/orm >= 1.0.4 "
243
254
  "so MODEL_FIELDS uses a list of field objects."
244
255
  )
245
- return fields
256
+ return fields or []
246
257
 
247
258
  def _pagination(self, take: Optional[int], skip: int, order_sql: str) -> str:
248
259
  if take is None:
249
260
  return ""
250
- if self._dialect == DIALECT_POSTGRES:
261
+ if self._dialect in (DIALECT_POSTGRES, DIALECT_SQLITE):
262
+ # SQLite has `LIMIT ... OFFSET` like PostgreSQL. Taking the
263
+ # `OFFSET ... FETCH NEXT` branch produces SQL SQLite does not understand.
251
264
  return f" LIMIT {take} OFFSET {skip}"
252
265
  order_prefix = "" if order_sql else " ORDER BY (SELECT NULL)"
253
266
  return f"{order_prefix} OFFSET {skip} ROWS FETCH NEXT {take} ROWS ONLY"
@@ -304,7 +317,7 @@ class AdapterTableClient(Generic[T]):
304
317
 
305
318
  cols = [k for k, v in data.items() if v is not None]
306
319
  values = [data[c] for c in cols]
307
- placeholders = ", ".join(["?" if self._dialect == DIALECT_MSSQL else "%s"] * len(cols))
320
+ placeholders = ", ".join(["%s" if self._dialect == DIALECT_POSTGRES else "?"] * len(cols))
308
321
  col_list = ", ".join(_quote(c, self._dialect) for c in cols)
309
322
  query = f"INSERT INTO {self._table_sql} ({col_list}) VALUES ({placeholders})"
310
323
  self._adapter.execute(query, values)
@@ -490,7 +503,7 @@ class AdapterTableClient(Generic[T]):
490
503
  query += f" ORDER BY {by_cols}"
491
504
  if has_take or has_skip:
492
505
  final_skip = _to_non_negative_int(skip)
493
- if self._dialect == DIALECT_POSTGRES:
506
+ if self._dialect in (DIALECT_POSTGRES, DIALECT_SQLITE):
494
507
  if has_take:
495
508
  query += f" LIMIT {_to_non_negative_int(take, 1)}"
496
509
  query += f" OFFSET {final_skip}"
@@ -508,34 +521,38 @@ class AdapterTableClient(Generic[T]):
508
521
  where_sql = _parse_where(self._model, where, params, self._dialect)
509
522
 
510
523
  # 1. Primary path: Native database SQL vector query execution (MSSQL VECTOR_DISTANCE / Postgres pgvector)
511
- try:
512
- field_sql = _quote(vector_field, self._dialect)
513
- if self._dialect == DIALECT_POSTGRES:
514
- op = "<=>" if distance_metric == "cosine" else ("<->" if distance_metric == "euclidean" else "<#>")
515
- query = f"SELECT *, ({field_sql} {op} %s::vector) AS distance FROM {self._table_sql}"
516
- query_params = [vec_json] + list(params.values())
517
- if where_sql:
518
- query += f" WHERE {field_sql} IS NOT NULL AND ({where_sql})"
519
- else:
520
- query += f" WHERE {field_sql} IS NOT NULL"
521
- query += f" ORDER BY distance ASC LIMIT {take}"
522
- else:
523
- placeholder = "?"
524
- query = f"SELECT TOP ({take}) *, VECTOR_DISTANCE('{distance_metric}', CAST({field_sql} AS VECTOR({dim}, float32)), CAST({placeholder} AS VECTOR({dim}, float32))) AS distance FROM {self._table_sql}{self._nolock}"
525
- query_params = [vec_json] + list(params.values())
526
- if where_sql:
527
- query += f" WHERE {field_sql} IS NOT NULL AND ({where_sql})"
524
+ # SQLite has no vector operators at all, so skip it entirely: building
525
+ # the SQL and catching the error would cost work and still end up on the
526
+ # in-memory path in step 2.
527
+ native_rows = None
528
+ if self._dialect != DIALECT_SQLITE:
529
+ try:
530
+ field_sql = _quote(vector_field, self._dialect)
531
+ if self._dialect == DIALECT_POSTGRES:
532
+ op = "<=>" if distance_metric == "cosine" else ("<->" if distance_metric == "euclidean" else "<#>")
533
+ query = f"SELECT *, ({field_sql} {op} %s::vector) AS distance FROM {self._table_sql}"
534
+ query_params = [vec_json] + list(params.values())
535
+ if where_sql:
536
+ query += f" WHERE {field_sql} IS NOT NULL AND ({where_sql})"
537
+ else:
538
+ query += f" WHERE {field_sql} IS NOT NULL"
539
+ query += f" ORDER BY distance ASC LIMIT {take}"
528
540
  else:
529
- query += f" WHERE {field_sql} IS NOT NULL"
530
- query += " ORDER BY distance ASC"
531
-
532
- native_rows = self._adapter.exec(query, query_params)
533
-
534
-
535
- if native_rows is not None:
536
- return native_rows
537
- except Exception:
538
- pass
541
+ placeholder = "?"
542
+ query = f"SELECT TOP ({take}) *, VECTOR_DISTANCE('{distance_metric}', CAST({field_sql} AS VECTOR({dim}, float32)), CAST({placeholder} AS VECTOR({dim}, float32))) AS distance FROM {self._table_sql}{self._nolock}"
543
+ query_params = [vec_json] + list(params.values())
544
+ if where_sql:
545
+ query += f" WHERE {field_sql} IS NOT NULL AND ({where_sql})"
546
+ else:
547
+ query += f" WHERE {field_sql} IS NOT NULL"
548
+ query += " ORDER BY distance ASC"
549
+
550
+ native_rows = self._adapter.exec(query, query_params)
551
+
552
+ if native_rows is not None:
553
+ return native_rows
554
+ except Exception:
555
+ pass
539
556
 
540
557
  # 2. Secondary fallback: In-memory similarity computation if DB engine lacks native vector extension
541
558
  rows = self.find_many(where=where)
@@ -0,0 +1,31 @@
1
+ [package]
2
+ name = "an5-adapters"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ description = "Standalone database adapter runtime for AN5 ORM (Rust) — connection handling, dialect-aware CRUD, aggregates and vector search."
6
+ license = "MIT"
7
+ repository = "https://github.com/an5ORM/an5Adapters"
8
+ homepage = "https://github.com/an5ORM/an5Adapters/tree/main/rust"
9
+ documentation = "https://docs.rs/an5-adapters"
10
+ readme = "README.md"
11
+ keywords = ["orm", "sql", "database", "postgres", "sqlx"]
12
+ categories = ["database", "database-implementations"]
13
+
14
+ [dependencies]
15
+ serde = { version = "1", features = ["derive"] }
16
+ serde_json = "1"
17
+ sqlx = { version = "0.8", default-features = false, features = [
18
+ "any",
19
+ "runtime-tokio",
20
+ "json",
21
+ ] }
22
+ tokio = { version = "1", features = ["rt"] }
23
+
24
+ [dev-dependencies]
25
+ tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
26
+ sqlx = { version = "0.8", default-features = false, features = [
27
+ "any",
28
+ "runtime-tokio",
29
+ "json",
30
+ "sqlite",
31
+ ] }
package/rust/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # an5-adapters
2
+
3
+ Database adapter runtime for [AN5 ORM](https://github.com/an5ORM/an5) in Rust.
4
+
5
+ `An5Adapter` owns the connection and the dialect; `TableClient` provides the ORM
6
+ CRUD surface on top of [`sqlx`](https://github.com/launchbadge/sqlx). Dialects are
7
+ detected from the connection string, so SQL Server, PostgreSQL and SQLite are
8
+ handled without configuration. This crate mirrors the TypeScript, Python, .NET
9
+ and Go adapter packages.
10
+
11
+ ## Install
12
+
13
+ ```toml
14
+ [dependencies]
15
+ an5-adapters = "0.1"
16
+ ```
17
+
18
+ The crate is driver-agnostic: it depends on `sqlx` with `any`, so the application
19
+ picks the driver it needs and enables the matching `sqlx` feature.
20
+
21
+ ```toml
22
+ [dependencies]
23
+ sqlx = { version = "0.8", default-features = false, features = ["any", "runtime-tokio", "postgres"] }
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ```rust
29
+ use an5_adapters::{An5Adapter, FindManyArgs};
30
+ use serde_json::json;
31
+
32
+ #[tokio::main]
33
+ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
34
+ // Register the drivers this binary needs, then hand the URL to the adapter.
35
+ sqlx::any::install_drivers(&[sqlx::postgres::any::DRIVER])?;
36
+ let db = An5Adapter::connect("postgres://localhost:5432/mydb").await?;
37
+
38
+ // Dynamic access, the Rust equivalent of `db.user` in TypeScript.
39
+ let users = db
40
+ .table("User")
41
+ .find_many(&FindManyArgs {
42
+ r#where: Some(json!({ "email": { "contains": "@example.com" } })),
43
+ take: 10,
44
+ ..Default::default()
45
+ })
46
+ .await?;
47
+
48
+ println!("{} users", users.len());
49
+ Ok(())
50
+ }
51
+ ```
52
+
53
+ Generated Rust clients (`an5-client`) wrap this crate and expose one typed handle
54
+ per model — `db.user().find_many(&UserFindManyArgs { .. })` — instead of the
55
+ dynamic `db.table("User")` access shown above.
56
+
57
+ ### Features
58
+
59
+ - `An5Adapter::connect` / `create_an5_adapter`, with the connection string read
60
+ from `DATABASE_URL` when omitted
61
+ - Dialect detection and identifier quoting for MSSQL, PostgreSQL and SQLite
62
+ - `TableClient`: `find_many`, `find_first`, `find_unique`, `count`, `create`,
63
+ `create_many`, `update`, `update_many`, `delete_many`, `upsert`, `aggregate`,
64
+ `group_by` and `vector_search`
65
+ - Transactions, raw SQL execution and view clients
66
+ - `base` module with the where/order-by builders, adapter metadata and vector
67
+ distance math
68
+
69
+ ## License
70
+
71
+ MIT