fosk 0.2.0

In-memory SQL-like query engine and lightweight data store for testing and prototyping.
Documentation

FOSK

fosk is a lightweight embedded SQL engine for Rust applications. It allows you to define in-memory collections, seed them with JSON objects, and query using a SQL-like syntax.


โœจ Features

  • In-memory database with collections (tables)
  • Configurable ID strategies: integer, UUID, or none
  • Simple JSON storage (serde_json::Value)
  • SQL parser with support for:
    • SELECT, WHERE, GROUP BY, HAVING
    • JOIN (inner, left, right, full)
    • Non-correlated FROM/JOIN subqueries with required aliases
    • ORDER BY, LIMIT, OFFSET
    • Parameterized queries (? placeholders, including arrays)
  • Test-friendly: create databases on the fly and seed them

Installation

In your Cargo.toml:

[dependencies]
fosk = "0.1.15"
serde_json = "1"

Quick example

use fosk::{Db, DbConfig};
use serde_json::json;

fn main() {
    let db = Db::new_with_config(DbConfig::int("id"));
    let people = db.create("people");

    let alice = people.add(json!({ "name": "Alice", "age": 30 })).unwrap();
    people.add_batch(json!([
        { "name": "Bob", "age": 25 },
        { "name": "Carol", "age": 28 }
    ]));

    let rows = db.query_with_args(
        "SELECT id, name, age FROM people WHERE id = ?",
        alice["id"].clone(),
    ).unwrap();

    println!("Query result: {rows:?}");
}

For a larger executable walkthrough of the public API, run:

cargo run --manifest-path examples/full_demo/Cargo.toml

The app is an independent Cargo project under examples/full_demo. It includes mock collection and schema files under examples/full_demo/mocks, including UUID IDs, auto-increment IDs, caller-provided None:* IDs, custom ID field names, nested objects, arrays, nullable fields, and relationship-shaped data. It also runs SQL examples over the loaded files: boolean filters, LIKE, IS NULL, parameterized IN (?), NOT IN, joins, COUNT(DISTINCT ...), SUM, AVG, GROUP BY, HAVING, ORDER BY, OFFSET, and LIMIT.

Example app map:


๐Ÿ“š Public API Guide

Create a database

Db is the user-facing database handle. It owns named collections, stores the default collection configuration, runs SQL queries, and manages schema/reference metadata.

use fosk::{Db, DbConfig, IdType};

let default_db = Db::new();
assert_eq!(default_db.get_config().id_type, IdType::Uuid);

let int_db = Db::new_with_config(DbConfig::int("id"));
assert_eq!(int_db.get_config(), DbConfig::int("id"));

let shared = Db::new_arc();
shared.create("people");
assert!(shared.get("people").is_some());

Runnable example: examples/full_demo/src/database_and_ids.rs

Configure IDs

DbConfig controls how a collection handles IDs. The database config is copied into collections created with Db::create; use Db::create_with_config when one collection needs a different strategy.

use fosk::{Db, DbConfig, IdType};
use serde_json::json;

let db = Db::new_with_config(DbConfig::int("id"));
let people = db.create("people");
let inserted = people.add(json!({ "name": "Ada" })).unwrap();
assert_eq!(inserted["id"], 1);

let sessions = db.create_with_config("sessions", DbConfig::uuid("session_id"));
let session = sessions.add(json!({ "user_id": 1 })).unwrap();
assert!(session["session_id"].as_str().is_some());

let logs = db.create_with_config("logs", DbConfig::none("key"));
assert!(logs.add(json!({ "key": "startup", "ok": true })).is_some());
assert!(logs.add(json!({ "ok": false })).is_none());

assert_eq!(logs.get_config().id_type, IdType::None);

Available constructors:

  • DbConfig::new() uses UUID IDs in the id field.
  • DbConfig::int("id") uses auto-increment integer IDs.
  • DbConfig::uuid("id") uses generated UUID strings.
  • DbConfig::none("id") requires callers to provide the ID field.

Runnable examples:

Manage collections

Collection names are stored case-insensitively. Creating an existing collection name replaces it with a new empty collection.

use fosk::{Db, DbConfig};
use serde_json::json;

let db = Db::new_with_config(DbConfig::int("id"));

let people = db.create("People");
people.add(json!({ "name": "Ada" }));

assert!(db.get("people").is_some());
assert_eq!(db.list_collections(), vec!["people"]);

assert!(db.drop_collection("PEOPLE"));
assert!(db.list_collections().is_empty());

db.create("orders");
db.clear();
assert!(db.list_collections().is_empty());

Runnable example: examples/full_demo/src/database_and_ids.rs

Work with documents

DbCollection exposes read, write, pagination, replacement, partial update, and deletion helpers. IDs are looked up as strings even when stored as numbers.

use fosk::{DbCollection, DbConfig};
use serde_json::json;

let people = DbCollection::new_coll("people", DbConfig::none("id"));

people.add(json!({ "id": "ada", "name": "Ada", "profile": { "city": "London" } }));
people.add_batch(json!([
    { "id": "grace", "name": "Grace" },
    { "id": "katherine", "name": "Katherine" }
]));

assert_eq!(people.count(), 3);
assert!(people.exists("ada"));
assert_eq!(people.get("ada").unwrap()["name"], "Ada");
assert_eq!(people.get_paginated(1, 1).len(), 1);

let updated = people
    .update_partial("ada", json!({ "profile": { "role": "engineer" } }))
    .unwrap();
assert_eq!(updated["profile"]["city"], "London");
assert_eq!(updated["profile"]["role"], "engineer");

let replaced = people
    .update("grace", json!({ "id": "grace", "name": "Grace Hopper" }))
    .unwrap();
assert_eq!(replaced["name"], "Grace Hopper");

assert!(people.delete("katherine").is_some());
assert_eq!(people.clear(), 2);

Runnable example: examples/full_demo/src/collection_crud.rs

Load existing data

use fosk::{Db, DbCollection, DbConfig};
use serde_json::json;
use std::ffi::OsString;

let db = Db::new();

let loaded = db.load_from_json(json!({
    "people": [
        { "id": 1, "name": "Ada" },
        { "id": 2, "name": "Grace" }
    ],
    "companies": [
        { "id": 1, "name": "ACME" }
    ]
}), true).unwrap();
assert_eq!(loaded, 2);

let people = DbCollection::new_coll("people", DbConfig::none("id"));
let inserted = people
    .load_from_json(json!([{ "id": 1, "name": "Ada" }]), true)
    .unwrap();
assert_eq!(inserted.len(), 1);

// File APIs accept OsString paths and return human-readable status strings.
// db.load_from_file(&OsString::from("collections.json"))?;
// people.load_from_file(&OsString::from("people.json"))?;

The keep flag controls incoming IDs:

  • true preserves IDs from loaded documents where possible.
  • false allows IDs to be regenerated according to the collection config.

Runnable examples:

Save data

use fosk::{Db, DbConfig};
use serde_json::json;
use std::ffi::OsString;

let db = Db::new_with_config(DbConfig::none("id"));
let people = db.create("people");
people.add(json!({ "id": 1, "name": "Ada" }));

let dump = db.write_to_json();
assert_eq!(dump["people"][0]["name"], "Ada");

// db.write_to_file(&OsString::from("collections.json"))?;
// people.write_to_file(&OsString::from("people.json"))?;

Runnable example: examples/full_demo/src/load_save.rs

Query data

Use query for SQL without placeholders and query_with_args for positional ? parameters. Pass one JSON value for one placeholder, or a JSON array for multiple placeholders. Arrays can also be used inside IN (?).

use fosk::{Db, DbConfig};
use serde_json::json;

let db = Db::new_with_config(DbConfig::none("id"));
db.create("people").add_batch(json!([
    { "id": 1, "name": "Ada", "age": 37, "city": "London" },
    { "id": 2, "name": "Grace", "age": 29, "city": "Arlington" }
]));

let older = db
    .query("SELECT name FROM people WHERE age > 30")
    .unwrap();
assert_eq!(older[0]["name"], "Ada");

let selected = db
    .query_with_args(
        "SELECT name FROM people WHERE id IN (?) ORDER BY id",
        json!([[1, 2]])
    )
    .unwrap();
assert_eq!(selected.len(), 2);

Supported SQL includes SELECT, WHERE, GROUP BY, HAVING, joins, non-correlated FROM/JOIN subqueries with aliases, ORDER BY, LIMIT, OFFSET, aggregate functions, aliases, and positional parameters.

Runnable examples:

References and expansion

References are foreign-key-like mappings between collection fields. They can be declared manually or inferred from collection naming conventions, then used to expand rows with related records.

use fosk::{Db, DbConfig};
use serde_json::json;

let db = Db::new_with_config(DbConfig::none("id"));
let people = db.create("people");
let orders = db.create("orders");

people.add(json!({ "id": 1, "name": "Ada" }));
orders.add(json!({ "id": 10, "person_id": 1, "total": 42.0 }));

assert!(db.create_reference("orders", "person_id", "people", "id"));
assert!(db.get_collection_column_ref("orders", "person_id").is_some());

let expanded = orders.expand_row(&orders.get("10").unwrap(), "people", &db);
assert_eq!(expanded["people"][0]["name"], "Ada");

infer_reference("orders", "people") looks for the referenced collection's conventional reference field name. For example, a people collection with ID key id expects people_id; a users collection with ID key user_id expects user_id.

Runnable examples:

Load collection schemas

Collection schemas can be loaded before inserting data. They define field names, field types, nullability, and optionally the collection ID behavior. References are not written in schema files; when schemas are loaded through Db, references are inferred after the load using the same inference rules already used by the database.

Compact collection schema format

A single collection schema is a JSON object where each key is a field name and each value is a compact type string:

{
  "user_id": "Id",
  "name": "String!",
  "age": "Int",
  "active": "Bool!"
}

Supported regular field types are:

  • Null
  • Bool
  • Int
  • Float
  • String
  • Object
  • Array

Nullability is declared with !:

  • "age": "Int" means age is nullable.
  • "age": "Int!" means age is non-nullable.

ID markers

One field can be marked as the collection ID field:

  • "id": "Id" uses auto-increment integer IDs and stores the field as Int!.
  • "uuid": "Uuid" uses generated UUID IDs and stores the field as String!.
  • "external_id": "None:String" uses caller-provided IDs and stores the field as String!.
  • "legacy_id": "None:Int" uses caller-provided IDs and stores the field as Int!.

None:Type ID markers are always non-nullable because the field is the collection ID. Nullable forms such as None:Int!, None:String!, and None:Null are rejected.

Load one collection schema

use fosk::Db;
use serde_json::json;

let db = Db::new();

db.load_collection_schema_from_json("users", json!({
    "user_id": "Id",
    "name": "String!",
    "age": "Int"
}));

For direct JSON values, the collection name must be provided because there is no filename or parent object key to infer it from.

Runnable example: examples/full_demo/src/schema_loading.rs

Schema files for one collection contain only the compact field map:

{
  "user_id": "Id",
  "name": "String!",
  "age": "Int"
}

The collection name is inferred from the file stem:

use fosk::Db;

let db = Db::new();

// Loads into the `users` collection.
// db.load_collection_schema_from_file(&"users.json".into())?;

Runnable examples:

Load all collection schemas

To load the whole database schema, use a JSON object keyed by collection name:

use fosk::Db;
use serde_json::json;

let db = Db::new();

db.load_schemas_from_json(json!({
    "users": {
        "user_id": "Id",
        "name": "String!"
    },
    "orders": {
        "order_id": "Uuid",
        "user_id": "Int!",
        "total": "Float!"
    }
}));

The same structure can be loaded from a file:

{
  "users": {
    "user_id": "Id",
    "name": "String!"
  },
  "orders": {
    "order_id": "Uuid",
    "user_id": "Int!",
    "total": "Float!"
  }
}
use fosk::Db;

let db = Db::new();

// db.load_schemas_from_file(&"schema.json".into())?;

Runnable examples:

Load schema on an existing collection

Collection handles can load only their own schema:

use fosk::Db;
use serde_json::json;

let db = Db::new();
let users = db.create_with_config("users", fosk::DbConfig::int("user_id"));

users.load_schema_from_json(json!({
    "user_id": "Id",
    "name": "String!"
}));

Collection-level schema loading validates any ID marker against the collection's existing DbConfig; it does not change the collection config, stored rows, or ID generator state.

Runnable examples:

Inspect schemas

use fosk::{Db, DbConfig, JsonPrimitive};
use serde_json::json;

let db = Db::new_with_config(DbConfig::none("id"));
let people = db.create("people");
people.add(json!({ "id": 1, "name": "Ada" }));

let schema = people.schema().unwrap();
assert_eq!(schema.fields["name"].ty, JsonPrimitive::String);

let schema_with_refs = db.schema_with_refs_of("people").unwrap();
assert_eq!(schema_with_refs.name, "people");

Runnable examples:

Useful metadata types:

  • JsonPrimitive classifies fields as Null, Bool, Int, Float, String, Object, or Array.
  • FieldInfo stores a field's primitive type and nullability.
  • SchemaDict stores field metadata for one collection.
  • SchemaWithRefs combines a collection schema with inbound and outbound references.
  • ReferenceColumn describes one relationship between two collection fields.

๐Ÿงช Testing & Seeding

Example test seed (see fixtures::seed_db):

pub fn seed_db() -> Db {
    let db = Db::new_with_config(DbConfig::none("id"));

    create_people(&db);
    create_products(&db);
    create_orders(&db);
    create_order_items(&db);

    db
}

Runnable examples:


โš ๏ธ Notes

  • Projections normally output unqualified field names (id, name), unless duplicates exist. In case of conflicts, names are disambiguated with their collection prefix (id, o.id).

๐Ÿ“„ License

Licensed under the MIT License. See LICENSE for details.