Skip to content

Standalone pypika-based ORM, backend abstraction, SQLite backend

Package: records-orm v0.1.0 Repository: https://github.com/sillohq/records-orm Source root: records-orm/records_orm/ Status: Backend abstraction layer implemented; Model/QuerySet/Fields planned


records-orm is a standalone async Python ORM with migrations, powered by pypika. It is the successor to sillo.record (which wraps Tortoise ORM), designed to be independently usable outside of Sillo.

"A standalone async Python ORM with migrations, powered by pypika."

Only the backend abstraction layer and configuration are implemented:

ComponentStatus
DatabaseConfigComplete
BaseBackend ABCComplete (13 abstract methods)
ColumnInfo / IndexInfoComplete
SQLiteBackendComplete (aiosqlite, WAL, transactions, savepoints)
get_backend factoryComplete
ModelPlanned
QuerySetPlanned
Fields (18 types)Planned
MigrationsPlanned
TransactionsPlanned
DatabaseManagerPlanned
CLIPlanned
pypika query builderPlanned
DependencyTypePurpose
pypika>=0.48 (core)SQL query builder
aiosqlite>=0.19.0 (sqlite extra)Async SQLite driver
asyncpg>=0.27.0 (postgres extra)Async PostgreSQL driver
aiomysql>=0.1.0 (mysql extra)Async MySQL driver
python-ulid>=2.0.0 (ulid extra)ULID generation
pydantic>=2.0 (pydantic extra)Schema validation

records-orm/
├── pyproject.toml
├── database/
│ └── migrations/ # Empty (template for user projects)
├── tests/ # Empty (tests planned)
└── records_orm/
├── __init__.py # Public API, __version__ = "0.1.0"
├── config.py # DatabaseConfig, DatabaseBackend
└── backends/
├── __init__.py # get_backend factory
├── base.py # BaseBackend ABC, ColumnInfo, IndexInfo
└── sqlite.py # SQLiteBackend
graph TD
    I[__init__.py] -->|imports| C[config.py]
    I -->|imports| BI[backends/__init__.py]
    BI -->|imports| B[backends/base.py]
    BI -->|imports| S[backends/sqlite.py]
    S -->|extends| B
    C -->|used by| BI

File paths (absolute):

ModulePath
__init__/Users/admin/sillo.build/records-orm/records_orm/__init__.py
config/Users/admin/sillo.build/records-orm/records_orm/config.py
backends/__init__/Users/admin/sillo.build/records-orm/records_orm/backends/__init__.py
backends/base/Users/admin/sillo.build/records-orm/records_orm/backends/base.py
backends/sqlite/Users/admin/sillo.build/records-orm/records_orm/backends/sqlite.py

Source: /Users/admin/sillo.build/records-orm/records_orm/config.py (108 lines)

@dataclass
class DatabaseConfig:
url: str = field(default_factory=lambda: os.getenv("DATABASE_URL", "sqlite://:memory:"))
backend: DatabaseBackend = DatabaseBackend.SQLITE
pool_size: int = field(default_factory=lambda: int(os.getenv("DB_POOL_SIZE", "5")))
max_overflow: int = field(default_factory=lambda: int(os.getenv("DB_MAX_OVERFLOW", "10")))
echo: bool = field(default_factory=lambda: os.getenv("DB_ECHO", "").lower() == "true")
ssl: bool = field(default_factory=lambda: os.getenv("DB_SSL", "").lower() == "true")
timezone: str = field(default_factory=lambda: os.getenv("DB_TIMEZONE", "UTC"))
charset: str = "utf8mb4"
ssl_ca: str | None = field(default_factory=lambda: os.getenv("DB_SSL_CA"))
ssl_cert: str | None = field(default_factory=lambda: os.getenv("DB_SSL_CERT"))
ssl_key: str | None = field(default_factory=lambda: os.getenv("DB_SSL_KEY"))
generate_schemas: bool = field(default_factory=lambda: os.getenv("DB_GENERATE_SCHEMAS", "").lower() == "true")
FieldTypeDefault / Env VarPurpose
urlstrDATABASE_URL or "sqlite://:memory:"Connection URL
backendDatabaseBackendSQLITE (auto-detected)Database engine
pool_sizeintDB_POOL_SIZE or 5Connection pool size
max_overflowintDB_MAX_OVERFLOW or 10Overflow connections
echoboolDB_ECHO or falseLog SQL queries
sslboolDB_SSL or falseEnable SSL
timezonestrDB_TIMEZONE or "UTC"Database timezone
charsetstr"utf8mb4"Character set
ssl_castr | NoneDB_SSL_CASSL CA certificate path
ssl_certstr | NoneDB_SSL_CERTSSL client certificate path
ssl_keystr | NoneDB_SSL_KEYSSL client key path
generate_schemasboolDB_GENERATE_SCHEMAS or falseAuto-create tables

Auto-detects backend from URL prefix:

URL PrefixBackend
postgres / postgresqlPOSTGRES
mysql / mariadbMYSQL
Anything elseSQLITE
@classmethod
def from_env(cls, *, prefix: str = "") -> DatabaseConfig
# Reads {PREFIX}_DATABASE_URL or DATABASE_URL
@classmethod
def sqlite(cls, path: str = ":memory:") -> DatabaseConfig
# URL: sqlite://{path}
@classmethod
def postgres(cls, database, password, *, user="postgres",
host="localhost", port=5432) -> DatabaseConfig
# URL: postgresql://{user}:{password}@{host}:{port}/{database}
@classmethod
def mysql(cls, database, password, *, user="root",
host="localhost", port=3306) -> DatabaseConfig
# URL: mysql://{user}:{password}@{host}:{port}/{database}
def to_dict(self) -> dict[str, Any]
# Serializes config to dict, with backend as its string value

Source: /Users/admin/sillo.build/records-orm/records_orm/config.py

class DatabaseBackend(Enum):
SQLITE = "sqlite"
POSTGRES = "postgres"
MYSQL = "mysql"

Used for backend detection and dispatching. The __post_init__ method on DatabaseConfig sets this automatically from the URL.


Source: /Users/admin/sillo.build/records-orm/records_orm/backends/base.py (151 lines)

class BaseBackend(ABC):
def __init__(self, url: str, **kwargs: Any):
self.url = url
self.kwargs = kwargs
self._pool = None
self._connected = False
MethodSignaturePurpose
connectasync def connect(self) -> NoneOpen the connection pool
disconnectasync def disconnect(self) -> NoneClose the connection pool
executeasync def execute(self, sql: str, params: list | None = None) -> NoneExecute a statement that returns no rows
fetch_oneasync def fetch_one(self, sql, params) -> dict | NoneFetch a single row as a dict
fetch_allasync def fetch_all(self, sql, params) -> list[dict]Fetch all rows as a list of dicts
fetch_valasync def fetch_val(self, sql, params) -> AnyFetch a single scalar value
beginasync def begin(self) -> AnyBegin a transaction, return a handle
placeholderdef placeholder(self, index: int) -> strParameter placeholder for position
introspect_tablesasync def introspect_tables(self) -> dict[str, list[ColumnInfo]]All tables and their columns
introspect_indexesasync def introspect_indexes(self, table: str) -> list[IndexInfo]All indexes for a table
table_existsasync def table_exists(self, table: str) -> boolCheck whether a table exists
auto_increment_sqldef auto_increment_sql(self) -> strSQL fragment for auto-increment PKs
json_typedef json_type(self) -> strSQL column type for JSON data
@property
def dialect(self) -> str:
return self.__class__.__name__.lower().replace("backend", "")

Returns "sqlite", "postgres", or "mysql".

Backendplaceholder(0)placeholder(1)
SQLite??
PostgreSQL$1$2
MySQL%s%s

Source: /Users/admin/sillo.build/records-orm/records_orm/backends/base.py

class ColumnInfo:
__slots__ = ("name", "type", "nullable", "default", "primary_key",
"auto_increment", "unique")
def __init__(self, name, type, nullable=True, default=None,
primary_key=False, auto_increment=False, unique=False)
SlotTypeDefaultPurpose
namestr(required)Column name
typestr(required)SQL type (e.g. "TEXT", "INTEGER")
nullableboolTrueWhether NULL is allowed
defaultstr | NoneNoneDefault value expression
primary_keyboolFalseWhether this is a primary key
auto_incrementboolFalseWhether this auto-increments
uniqueboolFalseWhether this has a unique constraint
class IndexInfo:
__slots__ = ("name", "table", "columns", "unique")
def __init__(self, name, table, columns: list[str], unique=False)
SlotTypePurpose
namestrIndex name
tablestrTable the index belongs to
columnslist[str]Column names in the index
uniqueboolWhether this is a unique index

Source: /Users/admin/sillo.build/records-orm/records_orm/backends/sqlite.py (184 lines)

class SQLiteBackend(BaseBackend):
def __init__(self, url: str, **kwargs):
super().__init__(url, **kwargs)
self._path = url.replace("sqlite://", "").replace("file:", "")
if not self._path or self._path == ":memory:":
self._path = ":memory:"
self._conn: aiosqlite.Connection | None = None
graph TD
    A[connect] --> B["aiosqlite.connect(path)"]
    B --> C["row_factory = aiosqlite.Row"]
    C --> D["PRAGMA journal_mode=WAL"]
    D --> E["PRAGMA foreign_keys=ON"]
    E --> F[_connected = True]
    F --> G[Ready for queries]
    G --> H[disconnect]
    H --> I["conn.close()"]
    I --> J["_conn = None, _connected = False"]
MethodImplementation
connectaiosqlite.connect(path), set row_factory, PRAGMAs
disconnectClose connection, set _connected = False
executeconn.execute(sql, params) then commit()
fetch_oneExecute, fetchone(), return dict(row) or None
fetch_allExecute, fetchall(), return [dict(r) for r in rows]
fetch_valExecute, fetchone(), return list(row)[0] or None
beginReturn _TransactionHandle(conn)
placeholderAlways "?"
introspect_tablesQuery sqlite_master WHERE type='table'
introspect_indexesPRAGMA index_list + PRAGMA index_info
table_existsQuery sqlite_master WHERE type='table' AND name=?
auto_increment_sql"INTEGER PRIMARY KEY AUTOINCREMENT"
json_type"TEXT" (SQLite has no native JSON type)
class _TransactionHandle:
def __init__(self, conn: aiosqlite.Connection)
async def __aenter__(self) -> Self # BEGIN
async def __aexit__(self, exc, ...) # COMMIT or ROLLBACK
def savepoint(self) -> _SavepointHandle
class _SavepointHandle:
def __init__(self, conn, name, parent)
async def __aenter__(self) -> Self # SAVEPOINT name
async def __aexit__(self, exc, ...) # RELEASE or ROLLBACK TO SAVEPOINT
def savepoint(self) -> _SavepointHandle # Nested savepoints

Savepoints are named sp_0, sp_1, sp_2, etc. Each __aexit__:

  • On exception: ROLLBACK TO SAVEPOINT sp_N.
  • On success: RELEASE SAVEPOINT sp_N.
  • Decrements parent’s depth counter.
async def _introspect_columns(self, table: str) -> list[ColumnInfo]

Uses PRAGMA table_info('{table}') to build ColumnInfo objects. Auto-increment detected when pk is true AND type contains "INTEGER".


Source: /Users/admin/sillo.build/records-orm/records_orm/backends/__init__.py (31 lines)

def get_backend(name: str) -> type[BaseBackend]:
graph TD
    A["get_backend('sqlite')"] --> B{In _BACKENDS dict?}
    B -->|Yes| C[Return class]
    B -->|No| D[Try lazy import postgres]
    D -->|ImportError| E[Try lazy import mysql]
    E -->|ImportError| F["Raise ValueError: pip install records-orm[{name}]"]
    D -->|Success| G[Add to dict, return]
    E -->|Success| G
NameClassExtra Required
sqliteSQLiteBackendrecords-orm[sqlite]
postgresPostgresBackend (lazy)records-orm[postgres]
mysqlMySQLBackend (lazy)records-orm[mysql]
backend_cls = get_backend("sqlite")
backend = backend_cls("sqlite://:memory:")
await backend.connect()

Source: /Users/admin/sillo.build/records-orm/records_orm/__init__.py (64 lines)

__version__ = "0.1.0"
__all__ = [
# Config
"DatabaseBackend", "DatabaseConfig",
# Connection (planned)
"DatabaseManager",
# Fields (planned)
"AutoIncrementField", "BooleanField", "CharField", "CreatedAtField",
"DateField", "DateTimeField", "DecimalField", "FloatField",
"ForeignKey", "IntField", "JSONField", "ManyToMany",
"PasswordField", "SlugField", "SoftDeleteField", "TextField",
"ULIDField", "UpdatedAtField",
# Migrations (planned)
"init", "make", "migrate", "plan", "rollback", "sql",
# Model (planned)
"Model",
# QuerySet (planned)
"QuerySet",
# Transactions (planned)
"TransactionContext", "transaction",
]
SymbolSourceStatus
DatabaseBackendconfig.pyWorking
DatabaseConfigconfig.pyWorking

All other 24 symbols reference modules that do not exist on disk. Importing them will raise ImportError at runtime.


sillo.record (in core/sillo/record/) is a fully implemented Eloquent-style convenience layer wrapping Tortoise ORM. records-orm is the standalone replacement.

graph TD
    subgraph "sillo.record (current)"
        A1[Model] --> A2[Tortoise ORM]
        A2 --> A3[aiosqlite / asyncpg / aiomysql]
    end

    subgraph "records-orm (target)"
        B1[Model] --> B2[pypika query builder]
        B2 --> B3[BaseBackend]
        B3 --> B4[aiosqlite / asyncpg / aiomysql]
    end
Aspectsillo.recordrecords-orm
Query builderTortoise ORM internalspypika
Backend enum4 values (includes MARIADB)3 values
pool_recycle fieldPresentNot present
generate_schemas defaulttruefalse
MariaDB detectionYes (in __post_init__)No (falls to MYSQL)
Type annotationsAnnotated[..., Doc(...)]Plain
StatusFully implementedBackend layer only
ModulePurpose
__init__.pyPublic API (37 __all__ symbols)
_bridge.pyBridge layer
casting.pyHasCasts mixin, CastRegistry
collection.pyCollection class
commands/Migration commands (init, make, migrate, plan, rollback)
config.pyDatabaseConfig (Tortoise-based)
console.pyCLI console commands
events.pyHasEvents, ModelObserver
exceptions.pyException handlers
factories.pyFactory, FactoryBuilder
fields.pyCustom fields (CreatedAtField, SlugField, etc.)
helpers.pyFixtureLoader, MigrationHelper, Seeder
logging.pyQueryLogEntry, QueryLogger
manager.pyDatabaseManager, setup_record
mixins/TimestampsMixin, SoftDeletesMixin, etc.
models.pyModel
pagination.pyTortoiseDataHandler
pydantic.pypydantic_model_from_tortoise
queries.pypaginate, count_by, explain, etc.
scopes.pyHasScopes, RecordManager, RecordQuerySet, ScopeRegistry
transactions.pyTransactionContext, begin, commit, rollback
def setup_record(app, config, *, model_modules=None) -> DatabaseManager

Stores the DatabaseManager in app.state["record"] and registers startup/shutdown hooks. This is the integration point between Sillo’s application lifecycle and the database.


FieldPurpose
AutoIncrementFieldAuto-incrementing integer PK
BooleanFieldTrue/False
CharFieldFixed-length string
CreatedAtFieldAuto-set on creation
DateFieldDate only
DateTimeFieldDate + time
DecimalFieldFixed-precision decimal
FloatFieldFloating point
ForeignKeyMany-to-one relationship
IntFieldInteger
JSONFieldJSON document
ManyToManyMany-to-many relationship
PasswordFieldHashed password
SlugFieldURL-safe slug
SoftDeleteFieldSoft delete timestamp
TextFieldVariable-length text
ULIDFieldULID primary key
UpdatedAtFieldAuto-set on update

The Model class will provide:

  • Declarative field definitions.
  • Table name derivation from class name.
  • CRUD operations via QuerySet.
  • Schema introspection via BaseBackend.

The QuerySet class will provide:

  • Fluent query building via pypika.
  • Filtering, ordering, limiting.
  • Aggregation (count, sum, avg).
  • Lazy evaluation.

CLI commands: init, make, migrate, plan, rollback, sql.

async with transaction(config) as tx:
await tx.execute("INSERT INTO ...")
await tx.execute("UPDATE ...")

Connection lifecycle management with:

  • Connection pooling.
  • Health checks.
  • Startup/shutdown hooks.

The tests/ directory is currently empty. The planned testing approach:

Each backend implementation will be tested against:

  • Connection lifecycle (connect/disconnect).
  • CRUD operations (execute, fetch_one, fetch_all, fetch_val).
  • Transaction management (begin, commit, rollback, savepoints).
  • Schema introspection (tables, columns, indexes).
  • Error handling (connection failures, syntax errors).
  • Full Model CRUD cycle.
  • Migration up/down.
  • Transaction isolation.
  • SQLite tests: in-memory database (:memory:).
  • PostgreSQL/MySQL tests: Docker containers (CI only).

End of document 45-RECORDS-ORM.md