├── .envrc ├── .gitignore ├── package.json ├── bun.lockb ├── Cargo.toml ├── packages └── real-time-sqlx │ ├── .gitignore │ ├── bun.lockb │ ├── index.ts │ ├── README.md │ ├── package.json │ ├── tsconfig.json │ └── src │ ├── conditions.ts │ ├── database.ts │ ├── subscribe.ts │ ├── paginate.ts │ ├── types.ts │ └── builders.ts ├── crates └── real-time-sqlx │ ├── src │ ├── operations.rs │ ├── tests │ │ ├── operations │ │ │ ├── 04_delete.json │ │ │ ├── 01_create.json │ │ │ ├── 03_update.json │ │ │ └── 02_create_many.json │ │ ├── queries │ │ │ ├── 02_many.json │ │ │ ├── 01_single.json │ │ │ ├── 06_empty.json │ │ │ ├── 07_in.json │ │ │ ├── 04_many_with_condition.json │ │ │ ├── 03_single_with_condition.json │ │ │ ├── 09_paginated_many.json │ │ │ ├── 08_paginated_single.json │ │ │ └── 05_nested_or.json │ │ ├── sql │ │ │ ├── 01_create.sql │ │ │ └── 02_insert.sql │ │ ├── utils.rs │ │ ├── dummy.rs │ │ ├── operations.rs │ │ ├── engine.rs │ │ └── queries.rs │ ├── backends │ │ ├── tauri.rs │ │ └── tauri │ │ │ ├── channels.rs │ │ │ └── macros.rs │ ├── backends.rs │ ├── tests.rs │ ├── lib.rs │ ├── error.rs │ ├── operations │ │ └── serialize.rs │ ├── queries │ │ ├── display.rs │ │ └── serialize.rs │ ├── macros.rs │ ├── database.rs │ ├── utils.rs │ ├── queries.rs │ └── database │ │ ├── mysql.rs │ │ ├── sqlite.rs │ │ └── postgres.rs │ ├── Cargo.toml │ └── README.md ├── LICENSE-MIT ├── flake.lock ├── flake.nix ├── LICENSE-APACHE ├── README.md └── docs └── real-time-engine.excalidraw.md /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | node_modules/ 3 | /.direnv 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "workspaces": [ 3 | "packages/*" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GnRlLeclerc/real-time-sqlx/HEAD/bun.lockb -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | 3 | resolver = "2" 4 | members = ["crates/real-time-sqlx"] 5 | -------------------------------------------------------------------------------- /packages/real-time-sqlx/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | 4 | # Output 5 | dist/ 6 | *.tgz 7 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/operations.rs: -------------------------------------------------------------------------------- 1 | //! Granular database operations and updates 2 | 3 | pub mod serialize; 4 | -------------------------------------------------------------------------------- /packages/real-time-sqlx/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GnRlLeclerc/real-time-sqlx/HEAD/packages/real-time-sqlx/bun.lockb -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/tests/operations/04_delete.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "delete", 3 | "table": "todos", 4 | "id": 1 5 | } 6 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/backends/tauri.rs: -------------------------------------------------------------------------------- 1 | //! Implementations for the Tauri backend 2 | 3 | pub mod channels; 4 | pub mod macros; 5 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/tests/queries/02_many.json: -------------------------------------------------------------------------------- 1 | { 2 | "return": "many", 3 | "table": "todos", 4 | "condition": null 5 | } 6 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/backends.rs: -------------------------------------------------------------------------------- 1 | //! Implementations for different backends. 2 | 3 | #[cfg(feature = "tauri")] 4 | pub mod tauri; 5 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/tests/queries/01_single.json: -------------------------------------------------------------------------------- 1 | { 2 | "return": "single", 3 | "table": "todos", 4 | "condition": null 5 | } 6 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/tests.rs: -------------------------------------------------------------------------------- 1 | //! Tests 2 | 3 | pub mod dummy; 4 | pub mod engine; 5 | pub mod operations; 6 | pub mod queries; 7 | pub mod utils; 8 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/tests/operations/01_create.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "create", 3 | "table": "todos", 4 | "data": { 5 | "title": "Fourth todo", 6 | "content": "This is the fourth todo" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/tests/sql/01_create.sql: -------------------------------------------------------------------------------- 1 | -- Create a dummy table 2 | CREATE TABLE todos ( 3 | id INTEGER PRIMARY KEY AUTOINCREMENT, 4 | title VARCHAR(255) NOT NULL, 5 | content VARCHAR(255) NOT NULL 6 | ); 7 | 8 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/tests/operations/03_update.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "update", 3 | "id": 3, 4 | "table": "todos", 5 | "data": { 6 | "title": "Updated todo", 7 | "content": "This todo was updated" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/real-time-sqlx/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./src/types"; 2 | export * from "./src/conditions"; 3 | export * from "./src/builders"; 4 | export * from "./src/database"; 5 | export * from "./src/subscribe"; 6 | export * from "./src/paginate"; 7 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Real-time SQLx library 2 | 3 | pub mod backends; 4 | pub mod database; 5 | pub mod error; 6 | pub mod macros; 7 | pub mod operations; 8 | pub mod queries; 9 | pub mod utils; 10 | 11 | #[cfg(test)] 12 | mod tests; 13 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/tests/sql/02_insert.sql: -------------------------------------------------------------------------------- 1 | -- Populate the table with dummy data 2 | INSERT INTO todos (title, content) VALUES 3 | ('First todo', 'This is the first todo'), 4 | ('Second todo', 'This is the second todo'), 5 | ('Third todo', 'This is the third todo'); 6 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/tests/queries/06_empty.json: -------------------------------------------------------------------------------- 1 | { 2 | "return": "single", 3 | "table": "todos", 4 | "condition": { 5 | "type": "single", 6 | "constraint": { 7 | "column": "id", 8 | "operator": "=", 9 | "value": -1 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/tests/queries/07_in.json: -------------------------------------------------------------------------------- 1 | { 2 | "return": "many", 3 | "table": "todos", 4 | "condition": { 5 | "type": "single", 6 | "constraint": { 7 | "column": "id", 8 | "operator": "in", 9 | "value": [1, 3] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/tests/queries/04_many_with_condition.json: -------------------------------------------------------------------------------- 1 | { 2 | "return": "many", 3 | "table": "todos", 4 | "condition": { 5 | "type": "single", 6 | "constraint": { 7 | "column": "id", 8 | "operator": "=", 9 | "value": 2 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/tests/queries/03_single_with_condition.json: -------------------------------------------------------------------------------- 1 | { 2 | "return": "single", 3 | "table": "todos", 4 | "condition": { 5 | "type": "single", 6 | "constraint": { 7 | "column": "id", 8 | "operator": "=", 9 | "value": 2 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/tests/queries/09_paginated_many.json: -------------------------------------------------------------------------------- 1 | { 2 | "return": "many", 3 | "table": "todos", 4 | "condition": null, 5 | "paginate": { 6 | "perPage": 1, 7 | "orderBy": { 8 | "column": "id", 9 | "order": "desc" 10 | }, 11 | "offset": 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/tests/queries/08_paginated_single.json: -------------------------------------------------------------------------------- 1 | { 2 | "return": "single", 3 | "table": "todos", 4 | "condition": null, 5 | "paginate": { 6 | "perPage": 1, 7 | "orderBy": { 8 | "column": "id", 9 | "order": "desc" 10 | }, 11 | "offset": 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/real-time-sqlx/README.md: -------------------------------------------------------------------------------- 1 | # Real-Time SQLx 2 | 3 | Frontend implementation of the real-time sqlx engine, in `Typescript`. 4 | The package manager is `bun`. 5 | 6 | Install dependencies: 7 | 8 | ```bash 9 | bun install 10 | ``` 11 | 12 | Develop: 13 | 14 | ```bash 15 | bun run build --watch 16 | ``` 17 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/tests/operations/02_create_many.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "create_many", 3 | "table": "todos", 4 | "data": [ 5 | { 6 | "title": "Fourth todo", 7 | "content": "This is the fourth todo" 8 | }, 9 | { 10 | "title": "Fifth todo", 11 | "content": "This is the fifth todo" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/error.rs: -------------------------------------------------------------------------------- 1 | //! Custom errors 2 | 3 | use thiserror::Error; 4 | 5 | /// Deserialization errors 6 | #[derive(Error, Debug)] 7 | pub enum DeserializeError { 8 | #[error("JSON Value could not be deserialized to FinalType")] 9 | IncompatibleValue(serde_json::Value), 10 | #[error("JSON Value could not be coerced to a Map")] 11 | IncompatibleMap(serde_json::Value), 12 | } 13 | -------------------------------------------------------------------------------- /packages/real-time-sqlx/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "real-time-sqlx", 3 | "version": "0.1.1", 4 | "author": { 5 | "name": "Thibaut de Saivre", 6 | "url": "https://github.com/GnRlLeclerc" 7 | }, 8 | "license": "MIT or Apache-2.0", 9 | "keywords": [ 10 | "database", 11 | "real-time", 12 | "tauri", 13 | "sqlite" 14 | ], 15 | "main": "dist/index.js", 16 | "module": "dist/index.js", 17 | "dependencies": { 18 | "@tauri-apps/api": "^2.1.1", 19 | "uuid": "^11.0.3" 20 | }, 21 | "devDependencies": {}, 22 | "peerDependencies": { 23 | "typescript": "^5.0.0" 24 | }, 25 | "description": "Frontend for the real-time SQLx engine", 26 | "files": [ 27 | "dist" 28 | ], 29 | "scripts": { 30 | "build": "tsc --project ." 31 | }, 32 | "type": "module", 33 | "types": "dist/index.d.ts" 34 | } 35 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/tests/queries/05_nested_or.json: -------------------------------------------------------------------------------- 1 | { 2 | "return": "many", 3 | "table": "todos", 4 | "condition": { 5 | "type": "or", 6 | "conditions": [ 7 | { 8 | "type": "single", 9 | "constraint": { 10 | "column": "id", 11 | "operator": "=", 12 | "value": 1 13 | } 14 | }, 15 | { 16 | "type": "or", 17 | "conditions": [ 18 | { 19 | "type": "single", 20 | "constraint": { 21 | "column": "id", 22 | "operator": "=", 23 | "value": 2 24 | } 25 | }, 26 | { 27 | "type": "single", 28 | "constraint": { 29 | "column": "id", 30 | "operator": "=", 31 | "value": 3 32 | } 33 | } 34 | ] 35 | } 36 | ] 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Thibaut de Saivre 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/real-time-sqlx/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Enable latest features 4 | "lib": [ 5 | "ESNext", 6 | "DOM" 7 | ], 8 | "target": "ESNext", 9 | "module": "ESNext", 10 | "moduleDetection": "force", 11 | "allowJs": true, 12 | // Bundler mode 13 | "moduleResolution": "bundler", 14 | "verbatimModuleSyntax": true, 15 | // Emit configuration 16 | "declaration": true, // Generate .d.ts files for type declarations 17 | "sourceMap": true, // Generate source maps (.map files) 18 | "outDir": "dist", // Output directory for compiled files 19 | "declarationMap": true, // Generate .d.ts.map files to map back to TypeScript source 20 | // Best practices 21 | "strict": true, 22 | "skipLibCheck": true, 23 | "noFallthroughCasesInSwitch": true, 24 | // Some stricter flags (disabled by default) 25 | "noUnusedLocals": false, 26 | "noUnusedParameters": false, 27 | "noPropertyAccessFromIndexSignature": false 28 | }, 29 | "include": [ 30 | "index.ts", 31 | "src" 32 | ], 33 | "exclude": [ 34 | "node_modules", 35 | "dist" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "real-time-sqlx" 3 | version = "0.1.1" 4 | edition = "2021" 5 | repository = "https://github.com/GnRlLeclerc/real-time-sqlx" 6 | authors = ["Thibaut de Saivre"] 7 | description = "Real-time SQLx backend for Tauri" 8 | license = "MIT OR Apache-2.0" 9 | readme = "README.md" 10 | keywords = ["database", "async", "real-time", "tauri", "sqlite"] 11 | categories = ["database", "asynchronous"] 12 | 13 | [features] 14 | postgres = ["sqlx/postgres"] 15 | mysql = ["sqlx/mysql"] 16 | sqlite = ["sqlx/sqlite"] 17 | tauri = ["dep:tauri", "dep:tokio"] 18 | 19 | [dev-dependencies] 20 | real-time-sqlx = { path = ".", features = [ 21 | "postgres", 22 | "mysql", 23 | "sqlite", 24 | "tauri", 25 | ] } 26 | tokio = { version = "1", features = ["full"] } 27 | sqlx = { version = "0.8", features = ["runtime-tokio"] } 28 | 29 | [dependencies] 30 | paste = "1" 31 | serde = { version = "1", features = ["derive"] } 32 | serde_json = "1" 33 | sqlx = { version = "0.8", features = [] } 34 | thiserror = "2" 35 | tauri = { version = "2", features = [], optional = true } 36 | tokio = { version = "1", features = ["full"], optional = true } 37 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/tests/utils.rs: -------------------------------------------------------------------------------- 1 | //! Test utilities 2 | 3 | use std::path::Path; 4 | 5 | use crate::{operations::serialize::GranularOperation, queries::serialize::QueryTree}; 6 | 7 | /// Read a serialized query into a QueryTree for execution 8 | pub(crate) fn read_serialized_query(name: &str) -> QueryTree { 9 | // Load the file 10 | let path = Path::new("src/tests/queries").join(name); 11 | let serialized_query = std::fs::read_to_string(path).unwrap(); 12 | 13 | // Deserialize the query from json 14 | let query: serde_json::Value = serde_json::from_str(&serialized_query).unwrap(); 15 | serde_json::from_value(query).unwrap() 16 | } 17 | 18 | /// Read a serialized operation into a GranularOperation for execution 19 | pub(crate) fn read_serialized_operation(name: &str) -> GranularOperation { 20 | // Load the file 21 | let path = Path::new("src/tests/operations").join(name); 22 | let serialized_operation = std::fs::read_to_string(path).unwrap(); 23 | 24 | // Deserialize the operation from json 25 | let operation: serde_json::Value = serde_json::from_str(&serialized_operation).unwrap(); 26 | serde_json::from_value(operation).unwrap() 27 | } 28 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1731319897, 6 | "narHash": "sha256-PbABj4tnbWFMfBp6OcUK5iGy1QY+/Z96ZcLpooIbuEI=", 7 | "owner": "nixos", 8 | "repo": "nixpkgs", 9 | "rev": "dc460ec76cbff0e66e269457d7b728432263166c", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "nixos", 14 | "ref": "nixos-unstable", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs", 22 | "rust-overlay": "rust-overlay" 23 | } 24 | }, 25 | "rust-overlay": { 26 | "inputs": { 27 | "nixpkgs": [ 28 | "nixpkgs" 29 | ] 30 | }, 31 | "locked": { 32 | "lastModified": 1731637922, 33 | "narHash": "sha256-6iuzRINXyPX4DfUQZIGafpJnzjFXjVRYMymB10/jFFY=", 34 | "owner": "oxalica", 35 | "repo": "rust-overlay", 36 | "rev": "db10c66da18e816030b884388545add8cf096647", 37 | "type": "github" 38 | }, 39 | "original": { 40 | "owner": "oxalica", 41 | "repo": "rust-overlay", 42 | "type": "github" 43 | } 44 | } 45 | }, 46 | "root": "root", 47 | "version": 7 48 | } 49 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/tests/dummy.rs: -------------------------------------------------------------------------------- 1 | //! Dummy data for testing 2 | 3 | use std::fs; 4 | 5 | use serde::{Deserialize, Serialize}; 6 | use sqlx::{prelude::FromRow, Pool, Sqlite}; 7 | 8 | /// A dummy struct for testing purposes 9 | #[derive(Debug, Clone, Serialize, Deserialize, FromRow, Eq, PartialEq)] 10 | pub struct Todo { 11 | pub id: i32, 12 | pub title: String, 13 | pub content: String, 14 | } 15 | 16 | #[cfg(feature = "sqlite")] 17 | /// Create an in-memory Sqlite database and return a pool connection 18 | pub async fn dummy_sqlite_database() -> Pool { 19 | sqlx::sqlite::SqlitePoolOptions::new() 20 | .connect("sqlite::memory:") 21 | .await 22 | .expect("Failed to create an in-memory sqlite database") 23 | } 24 | 25 | #[cfg(feature = "sqlite")] 26 | /// Create and seed a Sqlite database from a pool connection 27 | pub async fn prepare_dummy_sqlite_database(pool: &Pool) { 28 | let mut tx = pool.begin().await.unwrap(); 29 | 30 | let create_stmt = fs::read_to_string("src/tests/sql/01_create.sql").unwrap(); 31 | let query = sqlx::query(&create_stmt); 32 | 33 | query 34 | .execute(&mut *tx) 35 | .await 36 | .expect("Failed to create a dummy database"); 37 | 38 | let insert_stmt = fs::read_to_string("src/tests/sql/02_insert.sql").unwrap(); 39 | let query = sqlx::query(&insert_stmt); 40 | 41 | query 42 | .execute(&mut *tx) 43 | .await 44 | .expect("Failed to insert dummy data"); 45 | 46 | tx.commit() 47 | .await 48 | .expect("Failed to prepare a dummy database"); 49 | } 50 | -------------------------------------------------------------------------------- /packages/real-time-sqlx/src/conditions.ts: -------------------------------------------------------------------------------- 1 | /** Query conditions */ 2 | 3 | import { 4 | ConditionType, 5 | type ConditionSerialized, 6 | type ConstraintSerialized, 7 | } from "./types"; 8 | 9 | // ************************************************************************* // 10 | // CONDITION CLASSES // 11 | // ************************************************************************* // 12 | 13 | /** Condition base class. Implements shared methods */ 14 | export class Condition { 15 | /** Serialize the condition to JSON */ 16 | toJSON(): ConditionSerialized { 17 | throw new Error("Cannot serialize base Condition class"); 18 | } 19 | 20 | /** Create a condition instance from a constraint */ 21 | static fromConstraint(constraint: ConstraintSerialized): Condition { 22 | return new ConditionSingle(constraint); 23 | } 24 | 25 | // Helper methods to build ConditionAnd & ConditionOr from an existing condition 26 | /** Build a new AND condition from an existing condition and an additional constraint. */ 27 | and(constraint: ConstraintSerialized): ConditionAnd { 28 | return new ConditionAnd([this, Condition.fromConstraint(constraint)]); 29 | } 30 | 31 | /** Build a new OR condition from an existing condition and an additional constraint. */ 32 | or(constraint: ConstraintSerialized): ConditionOr { 33 | return new ConditionOr([this, Condition.fromConstraint(constraint)]); 34 | } 35 | } 36 | 37 | /** Empty condition. Is only possible for toplevel conditions. */ 38 | export class ConditionNone extends Condition {} 39 | 40 | /** Condition with a single constraint */ 41 | export class ConditionSingle extends Condition { 42 | constructor(public constraint: ConstraintSerialized) { 43 | super(); 44 | } 45 | 46 | toJSON(): ConditionSerialized { 47 | return { 48 | type: ConditionType.Single, 49 | constraint: this.constraint, 50 | }; 51 | } 52 | } 53 | 54 | /** Condition with multiple joint constraints */ 55 | export class ConditionAnd extends Condition { 56 | constructor(public conditions: Condition[]) { 57 | super(); 58 | } 59 | 60 | toJSON(): ConditionSerialized { 61 | return { 62 | type: ConditionType.And, 63 | conditions: this.conditions.map((c) => c.toJSON()), 64 | }; 65 | } 66 | } 67 | 68 | /** Condition with multiple alternative constraints */ 69 | export class ConditionOr extends Condition { 70 | constructor(public conditions: Condition[]) { 71 | super(); 72 | } 73 | 74 | toJSON(): ConditionSerialized { 75 | return { 76 | type: ConditionType.Or, 77 | conditions: this.conditions.map((c) => c.toJSON()), 78 | }; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Real-time library for SQLx"; 3 | 4 | inputs = { 5 | nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 6 | 7 | rust-overlay.url = "github:oxalica/rust-overlay"; 8 | rust-overlay.inputs.nixpkgs.follows = "nixpkgs"; 9 | }; 10 | 11 | outputs = 12 | { 13 | self, 14 | nixpkgs, 15 | rust-overlay, 16 | }: 17 | let 18 | # Supported systems 19 | systems = [ 20 | "aarch64-linux" 21 | "i686-linux" 22 | "x86_64-linux" 23 | "aarch64-darwin" 24 | "x86_64-darwin" 25 | ]; 26 | 27 | forAllSystems = nixpkgs.lib.genAttrs systems; 28 | in 29 | { 30 | devShell = forAllSystems ( 31 | system: 32 | let 33 | overlays = [ (import rust-overlay) ]; 34 | pkgs = import nixpkgs { 35 | inherit system overlays; 36 | }; 37 | makePkgConfigPath = pkgs.lib.makeSearchPathOutput "out" "lib/pkgconfig"; 38 | in 39 | pkgs.mkShell { 40 | nativeBuildInputs = with pkgs; [ 41 | at-spi2-atk 42 | atkmm 43 | cairo 44 | gdk-pixbuf 45 | glib 46 | gobject-introspection 47 | gobject-introspection.dev 48 | gtk3 49 | harfbuzz 50 | librsvg 51 | libsoup_3 52 | pango 53 | webkitgtk_4_1 54 | webkitgtk_4_1.dev 55 | 56 | # Additional libraries not mentionned 57 | openssl 58 | libsysprof-capture 59 | libthai 60 | libdatrie 61 | libselinux 62 | lerc 63 | libsepol 64 | xorg.libXdmcp 65 | util-linux.dev 66 | pcre2 67 | sqlite 68 | libpsl 69 | libxkbcommon 70 | libepoxy 71 | xorg.libXtst 72 | nghttp2 73 | ]; 74 | 75 | # Required for pkg-config to find the libraries 76 | packages = with pkgs; [ 77 | pkgconf 78 | 79 | rust-bin.stable.latest.default 80 | ]; 81 | 82 | # https://github.com/tauri-apps/tauri/issues/8929 83 | NO_STRIP = "true"; 84 | 85 | PKG_CONFIG_PATH = 86 | with pkgs; 87 | makePkgConfigPath [ 88 | glib.dev 89 | libsoup_3.dev 90 | webkitgtk_4_1.dev 91 | at-spi2-atk.dev 92 | gtk3.dev 93 | gdk-pixbuf.dev 94 | cairo.dev 95 | pango.dev 96 | harfbuzz.dev 97 | ]; 98 | } 99 | ); 100 | }; 101 | } 102 | -------------------------------------------------------------------------------- /packages/real-time-sqlx/src/database.ts: -------------------------------------------------------------------------------- 1 | /** The real-time sqlx entrypoint class */ 2 | 3 | import { InitialQueryBuilder } from "./builders"; 4 | import { 5 | OperationType, 6 | type CreateData, 7 | type FinalValue, 8 | type Indexable, 9 | type OperationNotificationCreate, 10 | type OperationNotificationCreateMany, 11 | type OperationNotificationDelete, 12 | type OperationNotificationUpdate, 13 | type UpdateData, 14 | } from "./types"; 15 | import { invoke } from "@tauri-apps/api/core"; 16 | 17 | export class SQLx> { 18 | /** Create a new query on a table */ 19 | select(table: T): InitialQueryBuilder { 20 | return new InitialQueryBuilder(table); 21 | } 22 | 23 | /** Builder to create an entry in a database */ 24 | async create( 25 | table: T, 26 | data: CreateData, 27 | ): Promise | null> { 28 | const operation = { 29 | type: OperationType.Create, 30 | table, 31 | data, 32 | }; 33 | 34 | return await invoke("execute", { operation }); 35 | } 36 | 37 | /** Builder to create many entries in a database */ 38 | async createMany( 39 | table: T, 40 | data: CreateData[], 41 | ): Promise | null> { 42 | const operation = { 43 | type: OperationType.CreateMany, 44 | table, 45 | data, 46 | }; 47 | 48 | return await invoke("execute", { operation }); 49 | } 50 | 51 | /** Builder to update an entry in a database */ 52 | async update( 53 | table: T, 54 | id: DB[T]["id"], 55 | data: UpdateData, 56 | ): Promise | null> { 57 | const operation = { 58 | type: OperationType.Update, 59 | id, 60 | table, 61 | data, 62 | }; 63 | 64 | return await invoke("execute", { operation }); 65 | } 66 | 67 | /** Builder to delete an entry in a database */ 68 | async delete( 69 | table: T, 70 | id: DB[T]["id"], 71 | ): Promise | null> { 72 | const operation = { 73 | type: OperationType.Delete, 74 | table, 75 | id, 76 | }; 77 | 78 | return await invoke("execute", { operation }); 79 | } 80 | 81 | /** Execute a raw prepared SQL query. Returns a list of rows. */ 82 | async rawOne( 83 | sql: string, 84 | values: FinalValue[] = [], 85 | ): Promise { 86 | return (await invoke("raw", { sql, values }))[0] ?? null; 87 | } 88 | 89 | /** Execute a raw prepared SQL query. Returns a list of rows. */ 90 | async rawMany(sql: string, values: FinalValue[] = []): Promise { 91 | return await invoke("raw", { sql, values }); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/README.md: -------------------------------------------------------------------------------- 1 | # Real-Time SQLx 2 | 3 | Rust backend for the real-time query engine. 4 | 5 | Run tests against Sqlite: 6 | 7 | ```bash 8 | cargo test 9 | ``` 10 | 11 | ## Behind the API 12 | 13 |
14 | Real-time engine schema 15 |
16 | 17 | ### Query Syntax Tree 18 | 19 | Queries are represented by a _syntax tree_ that encodes a subset of SQL. They have two functions: 20 | 21 | - Being converted into SQL queries for fetching data once (or initially when creating a subscription). 22 | - Checking if an `OperationNotification` that just occured affects the current query subscription. 23 | 24 | ### Channels 25 | 26 | Tauri channels enable the backend to send data to the frontend. In `real-time-sqlx`, channels are used to send `OperationNotifications` so that the frontend updates its store accordingly. 27 | 28 | When a subscription is created, the frontend sends 3 elements to the backend: 29 | 30 | - A `QueryTree` 31 | - A channel instance 32 | - A subscription `uuid` key (for targeted removal triggered by the frontend) 33 | 34 | The `(QueryTree, Channel)` tuples are stored on a **per-table** basis, meaning that `OperationNotifications` are only checked against the current active subscriptions of their respective table. This is easy to implement and generalize to as many tables as required, but not recommended for high usage cases (in multi-user cases, you should separate subscription families further in order to avoid checking all table operations against all active subscriptions of the same table). 35 | 36 | ### Granular Operations 37 | 38 | Similarly to queries, database operations like `INSERT`, `DELETE` and `UPDATE` are represented by the `GranularOperation` enum. When executed, they are converted into an `Option`, which is `None` if the operation failed (represented by `null` in the frontend). 39 | 40 | Every time a `GranularOperation` succeeds, its resulting `OperationNotification` is used to see which subscriptions of the related table are affected by it. If an `OperationNotification` matches a query, it is send to the frontend via its corresponding channel. 41 | Exception: if an `OperationNotification::Update` does not match a query, an `OperationNotification::Delete` is sent to the channel. This causes the channel to remove the element of corresponding ID from its cache, in case a previously matching element was altered in a way that makes it not match the query anymore. 42 | 43 | ### Real Time Dispatcher 44 | 45 | The heart of the engine is the `RealTimeDispatcher` struct. It holds, for each declared `(table name, table struct)` pair, an instance of `HashMap` locked in a thread-safe and async-safe way behind a `RwLock`. 46 | 47 | It is responsible for adding and removing supscriptions, and it processes `GranularOperations` before checking their related queries. One singleton instance is owned and managed by Tauri and passed as an argument to the Tauri commands. 48 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/operations/serialize.rs: -------------------------------------------------------------------------------- 1 | //! Serialize and deserialize database operations from JSON 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use crate::{error::DeserializeError, queries::serialize::FinalType}; 6 | 7 | /// Generic JSON object type 8 | pub type JsonObject = serde_json::Map; 9 | 10 | /// Coerce a JSON value to a JSON object 11 | pub fn object_from_value(value: serde_json::Value) -> Result { 12 | match value { 13 | serde_json::Value::Object(obj) => Ok(obj), 14 | value => Err(DeserializeError::IncompatibleValue(value)), 15 | } 16 | } 17 | 18 | /// Coerce a JSON value to a JSON object array 19 | pub fn object_array_from_value( 20 | value: serde_json::Value, 21 | ) -> Result, DeserializeError> { 22 | match value { 23 | serde_json::Value::Array(array) => { 24 | let mut objects = Vec::new(); 25 | for item in array { 26 | objects.push(object_from_value(item)?); 27 | } 28 | Ok(objects) 29 | } 30 | value => Err(DeserializeError::IncompatibleValue(value)), 31 | } 32 | } 33 | 34 | /// Entities related to a specific table 35 | pub trait Tabled { 36 | fn get_table(&self) -> &str; 37 | } 38 | 39 | /// An incoming granular operation to be performed in the database 40 | /// The data can be partial or complete, depending on the operation. 41 | #[derive(Debug, Clone, Serialize, Deserialize)] 42 | #[serde(tag = "type")] 43 | pub enum GranularOperation { 44 | #[serde(rename = "create")] 45 | Create { table: String, data: JsonObject }, 46 | #[serde(rename = "create_many")] 47 | CreateMany { 48 | table: String, 49 | data: Vec, 50 | }, 51 | #[serde(rename = "update")] 52 | Update { 53 | table: String, 54 | id: FinalType, 55 | data: JsonObject, 56 | }, 57 | #[serde(rename = "delete")] 58 | Delete { table: String, id: FinalType }, 59 | } 60 | 61 | impl Tabled for GranularOperation { 62 | /// Helper method to get the table name from the operation 63 | fn get_table(&self) -> &str { 64 | match self { 65 | GranularOperation::Create { table, .. } => table, 66 | GranularOperation::CreateMany { table, .. } => table, 67 | GranularOperation::Update { table, .. } => table, 68 | GranularOperation::Delete { table, .. } => table, 69 | } 70 | } 71 | } 72 | 73 | /// An outgoing operation notification to be sent to clients 74 | /// The data sent back is always complete, hence the generic parameter. 75 | #[derive(Debug, Clone, Serialize, Deserialize)] 76 | #[serde(tag = "type")] 77 | pub enum OperationNotification { 78 | #[serde(rename = "create")] 79 | Create { table: String, data: T }, 80 | #[serde(rename = "create_many")] 81 | CreateMany { table: String, data: Vec }, 82 | #[serde(rename = "update")] 83 | Update { 84 | table: String, 85 | id: FinalType, 86 | data: T, 87 | }, 88 | #[serde(rename = "delete")] 89 | Delete { 90 | table: String, 91 | id: FinalType, 92 | data: T, 93 | }, 94 | } 95 | 96 | impl Tabled for OperationNotification { 97 | /// Helper method to get the table name from the operation 98 | fn get_table(&self) -> &str { 99 | match self { 100 | OperationNotification::Create { table, .. } => table, 101 | OperationNotification::CreateMany { table, .. } => table, 102 | OperationNotification::Update { table, .. } => table, 103 | OperationNotification::Delete { table, .. } => table, 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/queries/display.rs: -------------------------------------------------------------------------------- 1 | //! Display unprepared Sqlite queries for debugging purposes 2 | 3 | use std::fmt; 4 | 5 | use crate::utils::format_list; 6 | 7 | use super::serialize::{ 8 | Condition, Constraint, ConstraintValue, FinalType, Operator, OrderBy, PaginateOptions, 9 | QueryTree, 10 | }; 11 | 12 | impl fmt::Display for FinalType { 13 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 14 | match self { 15 | FinalType::Number(number) => { 16 | if number.is_f64() { 17 | write!(f, "{}", number.as_f64().unwrap()) 18 | } else { 19 | write!(f, "{}", number.as_i64().unwrap()) 20 | } 21 | } 22 | FinalType::String(string) => write!(f, "'{string}'"), 23 | FinalType::Bool(bool) => write!(f, "{}", if *bool { 1 } else { 0 }), 24 | FinalType::Null => write!(f, "NULL"), 25 | } 26 | } 27 | } 28 | 29 | impl fmt::Display for ConstraintValue { 30 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 31 | match self { 32 | ConstraintValue::Final(value) => write!(f, "{}", value), 33 | ConstraintValue::List(list) => { 34 | write!(f, "{}", format_list(&list, ", ")) 35 | } 36 | } 37 | } 38 | } 39 | 40 | impl fmt::Display for Operator { 41 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 42 | match self { 43 | Operator::Equal => write!(f, "="), 44 | Operator::LessThan => write!(f, "<"), 45 | Operator::GreaterThan => write!(f, ">"), 46 | Operator::LessThanOrEqual => write!(f, "<="), 47 | Operator::GreaterThanOrEqual => write!(f, ">="), 48 | Operator::NotEqual => write!(f, "!="), 49 | Operator::In => write!(f, "in"), 50 | Operator::Like => write!(f, "like"), 51 | Operator::ILike => write!(f, "ilike"), 52 | } 53 | } 54 | } 55 | 56 | impl fmt::Display for Constraint { 57 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 58 | write!(f, "\"{}\" {} {}", self.column, self.operator, self.value) 59 | } 60 | } 61 | 62 | impl fmt::Display for Condition { 63 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 64 | match self { 65 | Condition::Single { constraint } => write!(f, "{}", constraint), 66 | Condition::Or { conditions } => { 67 | write!(f, "({})", format_list(&conditions, " OR ")) 68 | } 69 | Condition::And { conditions } => { 70 | write!(f, "({})", format_list(&conditions, " AND ")) 71 | } 72 | } 73 | } 74 | } 75 | 76 | impl fmt::Display for OrderBy { 77 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 78 | match self { 79 | OrderBy::Asc(column) => write!(f, "ORDER BY {} ASC", column), 80 | OrderBy::Desc(column) => write!(f, "ORDER BY {} DESC", column), 81 | } 82 | } 83 | } 84 | 85 | impl fmt::Display for PaginateOptions { 86 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 87 | if let Some(order) = &self.order_by { 88 | write!(f, "{} ", order)?; 89 | } 90 | write!(f, "LIMIT {} ", self.per_page)?; 91 | 92 | if let Some(offset) = self.offset { 93 | write!(f, "OFFSET {}", offset)?; 94 | } 95 | 96 | Ok(()) 97 | } 98 | } 99 | 100 | impl fmt::Display for QueryTree { 101 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 102 | write!(f, "SELECT * FROM {}", self.table)?; 103 | 104 | if let Some(condition) = &self.condition { 105 | write!(f, " WHERE {} ", condition)?; 106 | } 107 | 108 | if let Some(paginate) = &self.paginate { 109 | write!(f, "{}", paginate)?; 110 | } 111 | Ok(()) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/macros.rs: -------------------------------------------------------------------------------- 1 | //! Helper macros to automatically generate static dispatcher code between models. 2 | 3 | pub extern crate paste; 4 | 5 | /// Macro that generates the static rows serialization dispatcher function, 6 | /// that given sqlite rows, serializes them to the appropriate model based on the table name. 7 | /// 8 | /// Example: 9 | /// ```ignore 10 | /// // Generate the function 11 | /// serialize_rows_static!(sqlite, ("todos", Todo), ("users", User)); 12 | /// 13 | /// // Use it to serialize `QueryData` to JSON, with a table name. 14 | /// let serialized: serde_json::Value = serialize_rows_static(&rows, "todos"); 15 | /// ``` 16 | #[macro_export] 17 | macro_rules! serialize_rows_static { 18 | ($db_type:ident, $(($table_name:literal, $struct:ty)),+ $(,)?) => { 19 | fn serialize_rows_static(data: &$crate::queries::serialize::QueryData<$crate::database_row!($db_type)>, table: &str) -> serde_json::Value { 20 | match table { 21 | $( 22 | $table_name => $crate::database::serialize_rows::<$struct, $crate::database_row!($db_type)>(data), 23 | )+ 24 | _ => panic!("Table not found"), 25 | } 26 | } 27 | }; 28 | } 29 | 30 | /// Macro that generates a static operation executor and serializer function, 31 | /// that given a granular operation, executes it, parses the result into the data structure 32 | /// corresponding to the table name, and serializes it to JSON. This is useful for simple operation 33 | /// processing, without real-time updates. 34 | /// 35 | /// Example: 36 | /// ```ignore 37 | /// // Generate the function` 38 | /// granular_operations!(sqlite, ("todos", Todo), ("users", User)); 39 | /// 40 | /// // Use it to execute a granular operation and serialize the result to JSON. 41 | /// let serialized: serde_json::Value = granular_operation_static(operation, &pool).await; 42 | /// ``` 43 | #[macro_export] 44 | macro_rules! granular_operations { 45 | ($db_type:ident, $(($table_name:literal, $struct:ty)),+ $(,)?) => { 46 | async fn granular_operation_static( 47 | operation: $crate::operations::serialize::GranularOperation, 48 | pool: &$crate::database_pool!($db_type), 49 | ) -> serde_json::Value { 50 | match operation.get_table() { 51 | $( 52 | $table_name => { 53 | // Dynamically invoke the correct database function based on $db_type 54 | let result: Option<$crate::operations::serialize::OperationNotification<$struct>> = 55 | $crate::granular_operation_fn!($db_type)(operation, pool).await; 56 | serde_json::to_value(result).unwrap() 57 | } 58 | )+ 59 | _ => panic!("Table not found"), 60 | } 61 | } 62 | }; 63 | } 64 | 65 | // ************************************************************************* // 66 | // HELPER MACROS - RESOLVE DATABASE SPECIFIC FUNCTIONS AND TYPES // 67 | // ************************************************************************* // 68 | 69 | /// Returns the appropriate database pool type based on the database type. 70 | #[macro_export] 71 | macro_rules! database_pool { 72 | (sqlite) => { 73 | sqlx::Pool 74 | }; 75 | (mysql) => { 76 | sqlx::Pool 77 | }; 78 | (postgresql) => { 79 | sqlx::Pool 80 | }; 81 | } 82 | 83 | /// Returns the appropriate database row type based on the database type. 84 | #[macro_export] 85 | macro_rules! database_row { 86 | (sqlite) => { 87 | sqlx::sqlite::SqliteRow 88 | }; 89 | (mysql) => { 90 | sqlx::mysql::MySqlRow 91 | }; 92 | (postgresql) => { 93 | sqlx::postgres::PgRow 94 | }; 95 | } 96 | 97 | /// Returns the appropriate granular operation processing function depending on the database type. 98 | #[macro_export] 99 | macro_rules! granular_operation_fn { 100 | (sqlite) => { 101 | $crate::database::sqlite::granular_operation_sqlite 102 | }; 103 | (mysql) => { 104 | $crate::database::mysql::granular_operation_mysql 105 | }; 106 | (postgresql) => { 107 | $crate::database::postgresql::granular_operation_postgresql 108 | }; 109 | } 110 | 111 | /// Returns the appropriate database query fetching function depending on the database type. 112 | #[macro_export] 113 | macro_rules! fetch_query_fn { 114 | (sqlite) => { 115 | $crate::database::sqlite::fetch_sqlite_query 116 | }; 117 | (mysql) => { 118 | $crate::database::mysql::fetch_mysql_query 119 | }; 120 | (postgresql) => { 121 | $crate::database::postgresql::fetch_postgresql_query 122 | }; 123 | } 124 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/tests/operations.rs: -------------------------------------------------------------------------------- 1 | //! Serialized operations tests 2 | 3 | use std::{fs, path::Path}; 4 | 5 | use crate::database::sqlite::granular_operation_sqlite; 6 | use crate::operations::serialize::{GranularOperation, OperationNotification}; 7 | use crate::tests::dummy::{dummy_sqlite_database, prepare_dummy_sqlite_database}; 8 | 9 | use super::dummy::Todo; 10 | use super::utils::read_serialized_operation; 11 | 12 | #[tokio::test] 13 | async fn test_deserialize_operations() { 14 | // Get the operations 15 | let operations_path = Path::new("src/tests/operations"); 16 | 17 | for entry in fs::read_dir(operations_path).unwrap() { 18 | let entry = entry.unwrap(); 19 | 20 | let serialized_operation = fs::read_to_string(entry.path()).unwrap(); 21 | 22 | // Deserialize the query from json 23 | let query: serde_json::Value = serde_json::from_str(&serialized_operation).unwrap(); 24 | serde_json::from_value::(query).expect(&format!( 25 | "Failed to deserialize operation: {}", 26 | entry.file_name().into_string().unwrap() 27 | )); 28 | } 29 | } 30 | 31 | // ************************************************************************* // 32 | // TESTING AGAINST SQLITE BACKEND // 33 | // ************************************************************************* // 34 | 35 | /// Test single row creation 36 | #[tokio::test] 37 | async fn test_sqlite_create() { 38 | let pool = dummy_sqlite_database().await; 39 | prepare_dummy_sqlite_database(&pool).await; 40 | 41 | let operation = read_serialized_operation("01_create.json"); 42 | let result = granular_operation_sqlite(operation, &pool).await; 43 | 44 | assert!(result.is_some()); 45 | let result: OperationNotification = result.unwrap(); 46 | 47 | match result { 48 | OperationNotification::Create { table: _, data } => { 49 | assert_eq!(data.id, 4); 50 | assert_eq!(data.title, "Fourth todo"); 51 | assert_eq!(data.content, "This is the fourth todo"); 52 | } 53 | _ => panic!("Expected a create operation"), 54 | } 55 | } 56 | 57 | /// Test multiple row creation 58 | #[tokio::test] 59 | async fn test_sqlite_create_many() { 60 | let pool = dummy_sqlite_database().await; 61 | prepare_dummy_sqlite_database(&pool).await; 62 | 63 | let operation = read_serialized_operation("02_create_many.json"); 64 | let result = granular_operation_sqlite(operation, &pool).await; 65 | 66 | assert!(result.is_some()); 67 | let result: OperationNotification = result.unwrap(); 68 | 69 | match result { 70 | OperationNotification::CreateMany { table: _, data } => { 71 | assert_eq!(data.len(), 2); 72 | 73 | let first_data = &data[0]; 74 | assert_eq!(first_data.id, 4); 75 | assert_eq!(first_data.title, "Fourth todo"); 76 | assert_eq!(first_data.content, "This is the fourth todo"); 77 | 78 | let second_data = &data[1]; 79 | assert_eq!(second_data.id, 5); 80 | assert_eq!(second_data.title, "Fifth todo"); 81 | assert_eq!(second_data.content, "This is the fifth todo"); 82 | } 83 | _ => panic!("Expected a create many operation"), 84 | } 85 | } 86 | 87 | /// Test single row update 88 | #[tokio::test] 89 | async fn test_sqlite_update() { 90 | let pool = dummy_sqlite_database().await; 91 | prepare_dummy_sqlite_database(&pool).await; 92 | 93 | let operation = read_serialized_operation("03_update.json"); 94 | let result = granular_operation_sqlite(operation, &pool).await; 95 | 96 | assert!(result.is_some()); 97 | let result: OperationNotification = result.unwrap(); 98 | 99 | match result { 100 | OperationNotification::Update { 101 | table: _, 102 | id: _, 103 | data, 104 | } => { 105 | assert_eq!(data.id, 3); 106 | assert_eq!(data.title, "Updated todo"); 107 | assert_eq!(data.content, "This todo was updated"); 108 | } 109 | _ => panic!("Expected an update operation"), 110 | } 111 | } 112 | 113 | /// Test single row deletion 114 | #[tokio::test] 115 | async fn test_sqlite_delete() { 116 | let pool = dummy_sqlite_database().await; 117 | prepare_dummy_sqlite_database(&pool).await; 118 | 119 | let operation = read_serialized_operation("04_delete.json"); 120 | let result = granular_operation_sqlite(operation, &pool).await; 121 | 122 | assert!(result.is_some()); 123 | let result: OperationNotification = result.unwrap(); 124 | 125 | match result { 126 | OperationNotification::Delete { .. } => { 127 | // Nothing to check here. The operation is not None, meaning 1 row ws affected 128 | } 129 | _ => panic!("Expected a delete operation"), 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/queries/serialize.rs: -------------------------------------------------------------------------------- 1 | //! Deserialize database queries from JSON 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use serde_json::Number; 5 | 6 | use crate::error::DeserializeError; 7 | 8 | /// Query final constraint value (ie "native" types) 9 | /// Prevents recursive lists of values 10 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 11 | #[serde(untagged)] 12 | pub enum FinalType { 13 | Number(Number), 14 | String(String), 15 | Bool(bool), 16 | Null, 17 | } 18 | 19 | /// For binding values to queries, JSON values must be converted to native types 20 | /// in order to avoid cases such as double quotes enclosed strings. 21 | impl TryFrom for FinalType { 22 | type Error = DeserializeError; 23 | 24 | fn try_from(value: serde_json::Value) -> Result { 25 | match value { 26 | serde_json::Value::Number(n) => Ok(FinalType::Number(n)), 27 | serde_json::Value::String(s) => Ok(FinalType::String(s)), 28 | serde_json::Value::Bool(b) => Ok(FinalType::Bool(b)), 29 | serde_json::Value::Null => Ok(FinalType::Null), 30 | value => Err(DeserializeError::IncompatibleValue(value)), 31 | } 32 | } 33 | } 34 | 35 | /// Query constraint value 36 | #[derive(Debug, Clone, Serialize, Deserialize)] 37 | #[serde(untagged)] 38 | pub enum ConstraintValue { 39 | Final(FinalType), 40 | List(Vec), 41 | } 42 | 43 | /// Constraint operator 44 | #[derive(Debug, Clone, Serialize, Deserialize)] 45 | pub enum Operator { 46 | #[serde(rename = "=")] 47 | Equal, 48 | #[serde(rename = "<")] 49 | LessThan, 50 | #[serde(rename = ">")] 51 | GreaterThan, 52 | #[serde(rename = "<=")] 53 | LessThanOrEqual, 54 | #[serde(rename = ">=")] 55 | GreaterThanOrEqual, 56 | #[serde(rename = "!=")] 57 | NotEqual, 58 | #[serde(rename = "in")] 59 | In, 60 | #[serde(rename = "like")] 61 | Like, 62 | #[serde(rename = "ilike")] 63 | ILike, 64 | } 65 | 66 | /// Query constraint 67 | #[derive(Debug, Clone, Serialize, Deserialize)] 68 | pub struct Constraint { 69 | pub column: String, 70 | pub operator: Operator, 71 | pub value: ConstraintValue, 72 | } 73 | 74 | /// Query condition (contains constraints) 75 | #[derive(Debug, Clone, Serialize, Deserialize)] 76 | #[serde(tag = "type")] 77 | pub enum Condition { 78 | #[serde(rename = "and")] 79 | And { conditions: Vec }, 80 | #[serde(rename = "or")] 81 | Or { conditions: Vec }, 82 | #[serde(rename = "single")] 83 | Single { constraint: Constraint }, 84 | } 85 | 86 | /// Query return type (single row vs multiple rows) 87 | #[derive(Debug, Clone, Serialize, Deserialize)] 88 | pub enum ReturnType { 89 | #[serde(rename = "single")] 90 | Single, 91 | #[serde(rename = "many")] 92 | Many, 93 | } 94 | 95 | /// Column and order for sorting 96 | #[derive(Debug, Clone, Serialize, Deserialize)] 97 | #[serde(tag = "order", content = "column")] 98 | pub enum OrderBy { 99 | #[serde(rename = "asc")] 100 | Asc(String), 101 | #[serde(rename = "desc")] 102 | Desc(String), 103 | } 104 | 105 | /// Pagination options 106 | #[derive(Debug, Clone, Serialize, Deserialize)] 107 | pub struct PaginateOptions { 108 | #[serde(rename = "perPage")] 109 | pub per_page: u64, 110 | pub offset: Option, 111 | #[serde(rename = "orderBy")] 112 | pub order_by: Option, 113 | } 114 | 115 | /// Final serialized query tree 116 | #[derive(Debug, Clone, Serialize, Deserialize)] 117 | pub struct QueryTree { 118 | #[serde(rename = "return")] 119 | pub return_type: ReturnType, 120 | pub table: String, 121 | pub condition: Option, 122 | pub paginate: Option, 123 | } 124 | 125 | /// Returned query data 126 | #[derive(Debug, Clone, Serialize, Deserialize)] 127 | #[serde(tag = "type", content = "data")] 128 | pub enum QueryData { 129 | #[serde(rename = "single")] 130 | Single(Option), 131 | #[serde(rename = "many")] 132 | Many(Vec), 133 | } 134 | 135 | /// Helper implementations for unwrapping query data 136 | impl QueryData { 137 | pub fn unwrap_single(self) -> D { 138 | match self { 139 | QueryData::Single(Some(data)) => data, 140 | QueryData::Single(None) => panic!("No data found"), 141 | QueryData::Many(_) => panic!("Expected single row, found multiple rows"), 142 | } 143 | } 144 | 145 | pub fn unwrap_optional_single(self) -> Option { 146 | match self { 147 | QueryData::Single(data) => data, 148 | QueryData::Many(_) => panic!("Expected single row, found multiple rows"), 149 | } 150 | } 151 | 152 | pub fn unwrap_many(self) -> Vec { 153 | match self { 154 | QueryData::Single(_) => panic!("Expected multiple rows, found single row"), 155 | QueryData::Many(data) => data, 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/backends/tauri/channels.rs: -------------------------------------------------------------------------------- 1 | //! Tauri Channel-related operation processing implementations. 2 | 3 | use std::{collections::HashMap, hash::RandomState}; 4 | 5 | use serde::Serialize; 6 | use tauri::ipc::Channel; 7 | use tokio::sync::RwLock; 8 | 9 | use crate::{ 10 | operations::serialize::{object_array_from_value, object_from_value, OperationNotification}, 11 | queries::{serialize::QueryTree, Checkable}, 12 | }; 13 | 14 | /// Process a database operation notification and notify the relevant 15 | /// Tauri channels about the change that occured. 16 | /// 17 | /// Returns a list of channel uuid identifiers that errored out and should be pruned. 18 | pub fn process_channel_event<'a, T>( 19 | channels: &'a HashMap)>, 20 | operation: &OperationNotification, 21 | ) -> Vec<&'a str> 22 | where 23 | T: Clone + Serialize, 24 | { 25 | let serialized_operation = serde_json::to_value(operation).unwrap(); 26 | let data = serialized_operation.get("data").unwrap(); 27 | 28 | // Channels that error out, scheduled for pruning at the end. 29 | let mut failing_channels: Vec<&str> = Vec::new(); 30 | 31 | match operation { 32 | // For single-row operations, we simply push the operation to the channel 33 | // if the query matches 34 | OperationNotification::Create { .. } | OperationNotification::Delete { .. } => { 35 | let object = object_from_value(data.clone()).unwrap(); 36 | 37 | for (key, (query, channel)) in channels.iter() { 38 | if query.check(&object) { 39 | // Send an item to the channel, or schedule the channel for deletion 40 | if channel.send(serialized_operation.clone()).is_err() { 41 | failing_channels.push(key); 42 | } 43 | } 44 | } 45 | } 46 | OperationNotification::Update { 47 | table, 48 | data: notif_data, 49 | id, 50 | } => { 51 | let object = object_from_value(data.clone()).unwrap(); 52 | 53 | for (key, (query, channel)) in channels.iter() { 54 | if query.check(&object) { 55 | if channel.send(serialized_operation.clone()).is_err() { 56 | failing_channels.push(key); 57 | } 58 | } else { 59 | // Trick: because the object has been updated, it is possible that the query 60 | // once matched it, but does not anymore. We send a false `Delete` 61 | // operation to the frontend to signal that if it ever had this object 62 | // in store, it must delete it. 63 | let delete_operation = serde_json::to_value(OperationNotification::Delete { 64 | table: table.clone(), 65 | data: notif_data.clone(), 66 | id: id.clone(), 67 | }) 68 | .unwrap(); 69 | 70 | if channel.send(delete_operation).is_err() { 71 | failing_channels.push(key); 72 | } 73 | } 74 | } 75 | } 76 | // For multiple-row operations, we check each row individually for matches against 77 | // the query. We build per-query personalized vectors of matching objects and send 78 | // them to the corresponding channels 79 | OperationNotification::CreateMany { 80 | data: unserialized_data, 81 | .. 82 | } => { 83 | let objects = object_array_from_value(data.clone()).unwrap(); 84 | 85 | for (key, (query, channel)) in channels.iter() { 86 | let mut matching_objects: Vec = Vec::new(); 87 | for (index, object) in objects.iter().enumerate() { 88 | if query.check(&object) { 89 | matching_objects.push(unserialized_data[index].clone()); 90 | } 91 | } 92 | 93 | if !matching_objects.is_empty() { 94 | let serialized_operation = 95 | serde_json::to_value(OperationNotification::CreateMany { 96 | table: "todos".to_string(), 97 | data: matching_objects, 98 | }) 99 | .unwrap(); 100 | if channel.send(serialized_operation).is_err() { 101 | failing_channels.push(key); 102 | } 103 | } 104 | } 105 | } 106 | }; 107 | 108 | // Return the channels that errored out 109 | failing_channels 110 | } 111 | 112 | /// Process a database operation notification, notify the relevant 113 | /// Tauri channels about the change that occured, and remove the Tauri 114 | /// channels that errored out. 115 | pub async fn process_event_and_update_channels( 116 | channels: &RwLock), RandomState>>, 117 | operation: &OperationNotification, 118 | ) where 119 | T: Clone + Serialize, 120 | { 121 | let subscriptions = channels.read().await; 122 | let failing_channels = process_channel_event(&subscriptions, operation); 123 | 124 | if !failing_channels.is_empty() { 125 | let mut subscriptions = channels.write().await; 126 | for key in failing_channels { 127 | subscriptions.remove(key); 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/tests/engine.rs: -------------------------------------------------------------------------------- 1 | //! Tests for the in-memory simple query engine. 2 | //! It must give the same matches as the SQL query engine in the tests. 3 | 4 | // ************************************************************************* // 5 | // TESTING AGAINST SQLITE BACKEND // 6 | // ************************************************************************* // 7 | 8 | use sqlx::FromRow; 9 | 10 | use crate::{ 11 | database::sqlite::fetch_sqlite_query, 12 | operations::serialize::object_from_value, 13 | queries::{serialize::QueryTree, Checkable}, 14 | }; 15 | 16 | use super::{ 17 | dummy::{dummy_sqlite_database, prepare_dummy_sqlite_database, Todo}, 18 | utils::read_serialized_query, 19 | }; 20 | 21 | /// Return the list of all todos in the default dummy database 22 | /// for comparison with the SQL query engine 23 | fn todos() -> Vec { 24 | vec![ 25 | Todo { 26 | id: 1, 27 | title: "First todo".to_string(), 28 | content: "This is the first todo".to_string(), 29 | }, 30 | Todo { 31 | id: 2, 32 | title: "Second todo".to_string(), 33 | content: "This is the second todo".to_string(), 34 | }, 35 | Todo { 36 | id: 3, 37 | title: "Third todo".to_string(), 38 | content: "This is the third todo".to_string(), 39 | }, 40 | ] 41 | } 42 | 43 | /// Returns a vector of the todos that match the input query 44 | fn filter_todos(query: &QueryTree) -> Vec { 45 | todos() 46 | .into_iter() 47 | .filter(|t| query.check(&object_from_value(serde_json::to_value(t).unwrap()).unwrap())) 48 | .collect() 49 | } 50 | 51 | /// Test single row fetching 52 | #[tokio::test] 53 | async fn test_engine_single() { 54 | let query = read_serialized_query("01_single.json"); 55 | let engine_todos = filter_todos(&query); 56 | 57 | // NOTE: the engine matches all 3 Todos, because the query actually does. 58 | // The real-time query engine does not account for "single" return type. 59 | // The frontend will have to handle this degenerate case where one random 60 | // row is fetched from the database without conditions strict enough for some reason. 61 | assert_eq!(engine_todos.len(), 3); 62 | } 63 | 64 | /// Test many row fetching 65 | #[tokio::test] 66 | async fn test_engine_many() { 67 | let pool = dummy_sqlite_database().await; 68 | prepare_dummy_sqlite_database(&pool).await; 69 | 70 | let query = read_serialized_query("02_many.json"); 71 | let result = fetch_sqlite_query(&query, &pool).await; 72 | let all_rows = result.unwrap_many(); 73 | 74 | let engine_todos = filter_todos(&query); 75 | 76 | assert_eq!(engine_todos.len(), all_rows.len()); 77 | } 78 | 79 | /// Test single row fetching with a condition 80 | #[tokio::test] 81 | async fn test_engine_single_with_condition() { 82 | let pool = dummy_sqlite_database().await; 83 | prepare_dummy_sqlite_database(&pool).await; 84 | 85 | let query = read_serialized_query("03_single_with_condition.json"); 86 | let result = fetch_sqlite_query(&query, &pool).await; 87 | let single_row = Todo::from_row(&result.unwrap_single()).unwrap(); 88 | 89 | let engine_todos = filter_todos(&query); 90 | 91 | assert_eq!(engine_todos.len(), 1); 92 | assert_eq!(engine_todos[0], single_row); 93 | } 94 | 95 | /// Test many row fetching with a condition returning a single row 96 | #[tokio::test] 97 | async fn test_engine_many_with_condition() { 98 | let pool = dummy_sqlite_database().await; 99 | prepare_dummy_sqlite_database(&pool).await; 100 | 101 | let query = read_serialized_query("04_many_with_condition.json"); 102 | let result = fetch_sqlite_query(&query, &pool).await; 103 | let single_row = Todo::from_row(&result.unwrap_many()[0]).unwrap(); 104 | 105 | let engine_todos = filter_todos(&query); 106 | 107 | assert_eq!(engine_todos.len(), 1); 108 | assert_eq!(engine_todos[0], single_row); 109 | } 110 | 111 | /// Test fetching many rows with a nested OR condition 112 | #[tokio::test] 113 | async fn test_engine_nested_or() { 114 | let pool = dummy_sqlite_database().await; 115 | prepare_dummy_sqlite_database(&pool).await; 116 | 117 | let query = read_serialized_query("05_nested_or.json"); 118 | let result = fetch_sqlite_query(&query, &pool).await; 119 | let all_rows = result.unwrap_many(); 120 | 121 | let engine_todos = filter_todos(&query); 122 | 123 | assert_eq!(engine_todos.len(), all_rows.len()); 124 | } 125 | 126 | /// Test single row fetching with no existing matching entry 127 | #[tokio::test] 128 | async fn test_engine_empty() { 129 | let pool = dummy_sqlite_database().await; 130 | prepare_dummy_sqlite_database(&pool).await; 131 | 132 | let query = read_serialized_query("06_empty.json"); 133 | let result = fetch_sqlite_query(&query, &pool).await; 134 | let single_row = result.unwrap_optional_single(); 135 | 136 | let engine_todos = filter_todos(&query); 137 | 138 | assert!(single_row.is_none()); 139 | assert_eq!(engine_todos.len(), 0); 140 | } 141 | 142 | /// Test `IN` operations with arrays 143 | #[tokio::test] 144 | async fn test_engine_in() { 145 | let pool = dummy_sqlite_database().await; 146 | prepare_dummy_sqlite_database(&pool).await; 147 | 148 | let query = read_serialized_query("07_in.json"); 149 | let result = fetch_sqlite_query(&query, &pool).await; 150 | let all_rows = result 151 | .unwrap_many() 152 | .into_iter() 153 | .map(|r| Todo::from_row(&r).unwrap()) 154 | .collect::>(); 155 | 156 | let engine_todos = filter_todos(&query); 157 | 158 | assert_eq!(engine_todos, all_rows); 159 | } 160 | -------------------------------------------------------------------------------- /packages/real-time-sqlx/src/subscribe.ts: -------------------------------------------------------------------------------- 1 | /** Helper functions for subscriptions */ 2 | 3 | import { Channel, invoke } from "@tauri-apps/api/core"; 4 | import { v4 as uuidv4 } from "uuid"; 5 | import { ConditionNone, type Condition } from "./conditions"; 6 | import { 7 | OperationType, 8 | QueryReturnType, 9 | type Indexable, 10 | type ManyQueryData, 11 | type OperationNotification, 12 | type SerializedQuery, 13 | type SingleQueryData, 14 | } from "./types"; 15 | 16 | // ************************************************************************* // 17 | // TYPES // 18 | // ************************************************************************* // 19 | 20 | /** Unsubscribe to a channel */ 21 | export type UnsubscribeFn = () => void; 22 | 23 | /** Fetch more from a paginated query. 24 | * Returns the amount of additionally fetched rows 25 | */ 26 | export type FetchMoreFn = () => Promise; 27 | 28 | export type UpdateSingleFn = ( 29 | data: T | null, 30 | updates: OperationNotification | null, 31 | ) => void; 32 | 33 | export type UpdateManyFn = ( 34 | data: T[], 35 | updates: OperationNotification | null, 36 | ) => void; 37 | 38 | // ************************************************************************* // 39 | // IMPLEMENTATIONS // 40 | // ************************************************************************* // 41 | 42 | /** Implementation of the subscription to a single optional value */ 43 | export const subscribeOne = ( 44 | table: string, 45 | condition: Condition, 46 | callback: UpdateSingleFn, 47 | ): UnsubscribeFn => { 48 | // Generate a unique subscription ID and an unsubscription function. 49 | const channelId = uuidv4(); 50 | const unsubscribe = () => invoke("unsubscribe", { channelId, table }); 51 | 52 | // Create the channel to receive updates 53 | const channel = new Channel>(); 54 | 55 | // Create the internal data store 56 | let internalData: T | null = null; 57 | 58 | // Set the channel callback 59 | channel.onmessage = (update) => { 60 | // Update cached internal data 61 | switch (update.type) { 62 | case OperationType.Delete: 63 | internalData = null; 64 | break; 65 | case OperationType.Create: 66 | case OperationType.Update: 67 | if (internalData !== null && internalData.id !== update.data.id) { 68 | break; 69 | } 70 | internalData = update.data; 71 | break; 72 | case OperationType.CreateMany: 73 | for (const data of update.data) { 74 | if (internalData !== null && internalData.id !== data.id) { 75 | break; 76 | } 77 | internalData = data; 78 | } 79 | break; 80 | } 81 | 82 | // Call the callback with the updated data 83 | callback(internalData, update); 84 | }; 85 | 86 | // Send the initial query to the database 87 | const query: SerializedQuery = { 88 | return: QueryReturnType.Single, 89 | table, 90 | condition: condition instanceof ConditionNone ? null : condition.toJSON(), 91 | paginate: null, 92 | }; 93 | invoke>("subscribe", { 94 | query, 95 | channel, 96 | channelId, 97 | }).then(({ data }) => { 98 | internalData = data; 99 | // Call the callback with the initial data 100 | callback(internalData, null); 101 | }); 102 | 103 | // Return the unsubscribe function 104 | return unsubscribe; 105 | }; 106 | 107 | /** Implementation of the subscription to a list of values */ 108 | export const subscribeMany = ( 109 | table: string, 110 | condition: Condition, 111 | callback: UpdateManyFn, 112 | ): UnsubscribeFn => { 113 | // Generate a unique subscription ID and an unsubscription function. 114 | const channelId = uuidv4(); 115 | const unsubscribe = () => invoke("unsubscribe", { channelId, table }); 116 | 117 | // Create the channel to receive updates 118 | const channel = new Channel>(); 119 | 120 | // Create the internal data 121 | let internalData: T[] = []; 122 | let internalMap: Record = {}; 123 | 124 | // Set the callback 125 | channel.onmessage = (update) => { 126 | // Update cached internal data 127 | switch (update.type) { 128 | case OperationType.Delete: 129 | if (internalMap[update.data.id as string | number] === undefined) { 130 | return; 131 | } 132 | delete internalMap[update.data.id as string | number]; 133 | internalData = Object.values(internalMap); 134 | break; 135 | case OperationType.Create: 136 | case OperationType.Update: 137 | internalMap[update.data.id as string | number] = update.data; 138 | internalData = Object.values(internalMap); 139 | break; 140 | case OperationType.CreateMany: 141 | for (const data of update.data) { 142 | internalMap[data.id as string | number] = data; 143 | } 144 | internalData = Object.values(internalMap); 145 | break; 146 | } 147 | 148 | // Call the callback 149 | callback(internalData, update); 150 | }; 151 | 152 | // Send the query to the database 153 | const query: SerializedQuery = { 154 | return: QueryReturnType.Many, 155 | table, 156 | condition: condition instanceof ConditionNone ? null : condition.toJSON(), 157 | paginate: null, 158 | }; 159 | invoke>("subscribe", { 160 | query, 161 | channel, 162 | channelId, 163 | }).then(({ data }) => { 164 | // Set the initial internal data 165 | data.forEach((d) => (internalMap[d.id as string | number] = d)); 166 | internalData = Object.values(internalMap); 167 | 168 | // Call the callback with the initial data 169 | callback(internalData, null); 170 | }); 171 | 172 | // Return the unsubscribe function 173 | return unsubscribe; 174 | }; 175 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/database.rs: -------------------------------------------------------------------------------- 1 | //! Query utilities and particularized database implementations 2 | //! Some implementations need to be particularized because of trait generics hell. 3 | 4 | use serde::Serialize; 5 | use sqlx::FromRow; 6 | 7 | use crate::{ 8 | queries::serialize::{ 9 | Condition, Constraint, ConstraintValue, FinalType, OrderBy, PaginateOptions, QueryData, 10 | QueryTree, 11 | }, 12 | utils::{placeholders, sanitize_identifier}, 13 | }; 14 | 15 | #[cfg(feature = "mysql")] 16 | pub mod mysql; 17 | 18 | #[cfg(feature = "postgres")] 19 | pub mod postgres; 20 | 21 | #[cfg(feature = "sqlite")] 22 | pub mod sqlite; 23 | 24 | /// Produce a prepared SQL string and a list of argument values for binding 25 | /// from a deserialized query, and for use in a SQLx query 26 | fn prepare_sqlx_query(query: &QueryTree) -> (String, Vec) { 27 | let mut string_query = "SELECT * FROM ".to_string(); 28 | let mut values = vec![]; 29 | string_query.push_str(&sanitize_identifier(&query.table)); 30 | 31 | if let Some(condition) = &query.condition { 32 | string_query.push_str(" WHERE "); 33 | let (placeholders, args) = condition.traverse(); 34 | string_query.push_str(&placeholders); 35 | values.extend(args); 36 | } 37 | 38 | if let Some(paginate) = &query.paginate { 39 | string_query.push_str(" "); 40 | let pagination = paginate.traverse(); 41 | string_query.push_str(&pagination.0); 42 | values.extend(pagination.1); 43 | } 44 | 45 | (string_query, values) 46 | } 47 | 48 | /// Serialize SQL rows to json by mapping them to an intermediate data model structure 49 | pub fn serialize_rows(data: &QueryData) -> serde_json::Value 50 | where 51 | T: for<'r> FromRow<'r, R> + Serialize, 52 | R: sqlx::Row, 53 | { 54 | match data { 55 | QueryData::Single(row) => match row { 56 | Some(row) => serde_json::json!(QueryData::Single(Some(T::from_row(row).unwrap()))), 57 | None => serde_json::json!(QueryData::Single(None::)), 58 | }, 59 | QueryData::Many(rows) => serde_json::json!(QueryData::Many( 60 | rows.iter() 61 | .map(|row| T::from_row(row).unwrap()) 62 | .collect::>() 63 | )), 64 | } 65 | } 66 | 67 | // ********************************************************************************************* // 68 | // Query Traversal Functions // 69 | // ********************************************************************************************* // 70 | 71 | /// Trait to normalize the traversal of query constraints and conditions 72 | trait Traversable { 73 | fn traverse(&self) -> (String, Vec); 74 | } 75 | 76 | impl Traversable for FinalType { 77 | /// Traverse a final constraint value 78 | fn traverse(&self) -> (String, Vec) { 79 | ("?".to_string(), vec![self.clone()]) 80 | } 81 | } 82 | 83 | impl Traversable for ConstraintValue { 84 | /// Traverse a query constraint value 85 | fn traverse(&self) -> (String, Vec) { 86 | match self { 87 | ConstraintValue::List(list) => (placeholders(list.len()), list.clone()), 88 | ConstraintValue::Final(value) => value.traverse(), 89 | } 90 | } 91 | } 92 | 93 | impl Traversable for Constraint { 94 | /// Traverse a query constraint 95 | fn traverse(&self) -> (String, Vec) { 96 | let (values_string_query, values) = self.value.traverse(); 97 | 98 | ( 99 | format!( 100 | "\"{}\" {} {}", 101 | self.column, self.operator, values_string_query 102 | ), 103 | values, 104 | ) 105 | } 106 | } 107 | 108 | impl Traversable for Condition { 109 | /// Traverse a query condition 110 | fn traverse(&self) -> (String, Vec) { 111 | match self { 112 | Condition::Single { constraint } => constraint.traverse(), 113 | Condition::Or { conditions } => reduce_constraints_list(conditions, " OR "), 114 | Condition::And { conditions } => reduce_constraints_list(conditions, " AND "), 115 | } 116 | } 117 | } 118 | 119 | impl Traversable for PaginateOptions { 120 | /// Traverse a query pagination options 121 | fn traverse(&self) -> (String, Vec) { 122 | let mut query_string = "".to_string(); 123 | let mut values: Vec = vec![]; 124 | 125 | if let Some(order) = &self.order_by { 126 | query_string.push_str( 127 | match order { 128 | OrderBy::Asc(col) => format!("ORDER BY {} ASC ", sanitize_identifier(col)), 129 | OrderBy::Desc(col) => format!("ORDER BY {} DESC ", sanitize_identifier(col)), 130 | } 131 | .as_str(), 132 | ); 133 | } else { 134 | // By default, if paginate options are present, order by ID descending 135 | query_string.push_str("ORDER BY id DESC "); 136 | } 137 | 138 | query_string.push_str("LIMIT ? "); 139 | values.push(FinalType::Number(self.per_page.into())); 140 | 141 | if let Some(offset) = self.offset { 142 | query_string.push_str("OFFSET ? "); 143 | values.push(FinalType::Number(offset.into())); 144 | } 145 | 146 | (query_string, values) 147 | } 148 | } 149 | 150 | /// Create a list of string queries and constraint values vectors from a list of 151 | /// conditions 152 | fn reduce_constraints_list(conditions: &[Condition], sep: &str) -> (String, Vec) { 153 | let mut placeholder_strings: Vec = vec![]; 154 | let mut total_values: Vec = vec![]; 155 | 156 | conditions.iter().for_each(|condition| { 157 | let (string_query, values) = condition.traverse(); 158 | placeholder_strings.push(string_query); 159 | total_values.extend(values); 160 | }); 161 | 162 | (format!("({})", placeholder_strings.join(sep)), total_values) 163 | } 164 | -------------------------------------------------------------------------------- /packages/real-time-sqlx/src/paginate.ts: -------------------------------------------------------------------------------- 1 | /** Subscription to a paginated query */ 2 | 3 | import { Channel, invoke } from "@tauri-apps/api/core"; 4 | import { ConditionNone, type Condition } from "./conditions"; 5 | import type { FetchMoreFn, UnsubscribeFn, UpdateManyFn } from "./subscribe"; 6 | import { 7 | OperationType, 8 | QueryReturnType, 9 | type Indexable, 10 | type ManyQueryData, 11 | type OperationNotification, 12 | type OrderBy, 13 | type PaginateOptions, 14 | type SerializedQuery, 15 | } from "./types"; 16 | import { v4 as uuidv4 } from "uuid"; 17 | 18 | const DEFAULT_ORDER = { column: "id", order: "desc" } as const; 19 | 20 | /** Sort an array of objects by a key. 21 | * If no option is given, sorts by decreasing index 22 | */ 23 | const sortBy = ( 24 | array: T[], 25 | orderBy: OrderBy | null = null, 26 | ): T[] => { 27 | const { column, order } = orderBy ?? DEFAULT_ORDER; 28 | return array.sort((a, b) => { 29 | if (order === "asc") { 30 | return a[column] >= b[column] ? 1 : -1; 31 | } else { 32 | return a[column] <= b[column] ? 1 : -1; 33 | } 34 | }); 35 | }; 36 | 37 | /** Check if an incoming item is in the pagination range yet by comparing its discriminant 38 | * field to that of the last item in the current pagination range 39 | */ 40 | const isInRange = ( 41 | item: T, 42 | lastValue: any | null, 43 | orderBy: OrderBy | null = null, 44 | ) => { 45 | const { column, order } = orderBy ?? DEFAULT_ORDER; 46 | 47 | if (lastValue === null) { 48 | return true; 49 | } 50 | if (order === "asc") { 51 | return item[column] <= lastValue; 52 | } else { 53 | return item[column] >= lastValue; 54 | } 55 | }; 56 | 57 | /** Update the last accepted discriminant value for the current pagination range */ 58 | const updateDiscriminant = ( 59 | sortedValues: T[], 60 | orderBy: OrderBy | null = null, 61 | ) => { 62 | if (sortedValues.length === 0) { 63 | return null; 64 | } 65 | const column = orderBy?.column ?? DEFAULT_ORDER.column; 66 | 67 | return sortedValues[sortedValues.length - 1][column]; 68 | }; 69 | 70 | /** Implementation of the subscription to a list of values */ 71 | export const paginate = ( 72 | table: string, 73 | condition: Condition, 74 | options: PaginateOptions, 75 | callback: UpdateManyFn, 76 | ): [UnsubscribeFn, FetchMoreFn] => { 77 | // Generate a unique subscription ID and an unsubscription function. 78 | const channelId = uuidv4(); 79 | const unsubscribe = () => invoke("unsubscribe", { channelId, table }); 80 | 81 | // Create the channel to receive updates 82 | const channel = new Channel>(); 83 | 84 | // Create the internal data 85 | let internalData: T[] = []; 86 | let internalMap: Record = {}; 87 | let lastDiscriminant: any = null; 88 | let anyLeft = true; 89 | 90 | // Set the callback 91 | channel.onmessage = (update) => { 92 | // Update cached internal data 93 | switch (update.type) { 94 | case OperationType.Delete: 95 | if (internalMap[update.data.id as string | number] === undefined) { 96 | return; 97 | } 98 | 99 | delete internalMap[update.data.id as string | number]; 100 | internalData = sortBy(Object.values(internalMap), options.orderBy); 101 | lastDiscriminant = updateDiscriminant(internalData, options.orderBy); 102 | break; 103 | 104 | case OperationType.Create: 105 | case OperationType.Update: 106 | if (!isInRange(update.data, lastDiscriminant, options.orderBy)) { 107 | anyLeft = true; 108 | return; 109 | } 110 | 111 | internalMap[update.data.id as string | number] = update.data; 112 | internalData = sortBy(Object.values(internalMap), options.orderBy); 113 | lastDiscriminant = updateDiscriminant(internalData, options.orderBy); 114 | break; 115 | 116 | case OperationType.CreateMany: 117 | let valid = 0; 118 | for (const data of update.data) { 119 | if (!isInRange(data, lastDiscriminant, options.orderBy)) { 120 | anyLeft = true; 121 | continue; 122 | } 123 | 124 | internalMap[data.id as string | number] = data; 125 | valid++; 126 | } 127 | if (valid === 0) { 128 | return; 129 | } 130 | 131 | internalData = sortBy(Object.values(internalMap), options.orderBy); 132 | lastDiscriminant = updateDiscriminant(internalData, options.orderBy); 133 | break; 134 | } 135 | 136 | // Call the callback for the paths that have not exited yet (i.e. a relevant operation happened) 137 | callback(internalData, update); 138 | }; 139 | 140 | // Send the query to the database with the initial pagination parameters 141 | const query: SerializedQuery = { 142 | return: QueryReturnType.Many, 143 | table, 144 | condition: condition instanceof ConditionNone ? null : condition.toJSON(), 145 | paginate: options ?? null, 146 | }; 147 | invoke>("subscribe", { 148 | query, 149 | channel, 150 | channelId, 151 | }).then(({ data }) => { 152 | // Set the initial internal data 153 | data.forEach((d) => (internalMap[d.id as string | number] = d)); 154 | internalData = sortBy(Object.values(internalMap), options.orderBy); 155 | lastDiscriminant = updateDiscriminant(internalData, options.orderBy); 156 | 157 | // Call the callback with the initial data 158 | callback(internalData, null); 159 | }); 160 | 161 | const fetchMore: FetchMoreFn = async () => { 162 | // Avoid backend calls if we already know that there is nothing left. 163 | if (!anyLeft) { 164 | return 0; 165 | } 166 | 167 | // Update the pagination options 168 | const paginate: PaginateOptions = { 169 | orderBy: options.orderBy, 170 | perPage: options.perPage, 171 | offset: internalData.length + (options.offset ?? 0), 172 | }; 173 | 174 | const query: SerializedQuery = { 175 | return: QueryReturnType.Many, 176 | table, 177 | condition: condition instanceof ConditionNone ? null : condition.toJSON(), 178 | paginate, 179 | }; 180 | 181 | const { data } = await invoke>("fetch", { 182 | query, 183 | channel, 184 | channelId, 185 | }); 186 | 187 | if (data.length === 0) { 188 | anyLeft = false; 189 | } 190 | 191 | // Merge the new data with the existing one 192 | data.forEach((d) => (internalMap[d.id as string | number] = d)); 193 | internalData = sortBy(Object.values(internalMap), options.orderBy); 194 | lastDiscriminant = updateDiscriminant(internalData, options.orderBy); 195 | 196 | // Call the callback with the new data 197 | callback(internalData, null); 198 | 199 | // Return the amount of affected rows 200 | return data.length; 201 | }; 202 | 203 | // Return the unsubscribe function 204 | return [unsubscribe, fetchMore]; 205 | }; 206 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/utils.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt, iter::repeat}; 2 | 3 | /// Utility function to format a list of displayable items with a specific 4 | /// separator 5 | /// 6 | /// Example: 7 | /// 1, 2, 3, condition1 OR condition2 OR condition3 8 | #[inline] 9 | pub(crate) fn format_list(items: &[T], separator: &str) -> String { 10 | items 11 | .iter() 12 | .map(|item| format!("{}", item).to_string()) 13 | .collect::>() 14 | .join(separator) 15 | } 16 | 17 | /// Utility function to format an iterator of displayable items with a 18 | /// specific separator 19 | /// 20 | /// Example: 21 | /// 1, 2, 3, condition1 OR condition2 OR condition3 22 | #[inline] 23 | pub(crate) fn format_iter>( 24 | items: I, 25 | separator: &str, 26 | ) -> String { 27 | items 28 | .into_iter() 29 | .map(|item| format!("{}", item).to_string()) 30 | .collect::>() 31 | .join(separator) 32 | } 33 | 34 | /// Create an owned vector of keys from a JSON object. 35 | /// The vector is not actually "ordered", rather it enables reading the values 36 | /// of multiple similar objects always in the same order for SQL insertion. 37 | #[inline] 38 | pub(crate) fn ordered_keys(object: &serde_json::Map) -> Vec { 39 | object.keys().map(|key| (*key).clone()).collect() 40 | } 41 | 42 | /// Convert a string with '?' placeholders to numbered '$1' placeholderss 43 | #[inline] 44 | pub(crate) fn to_numbered_placeholders(query: &str) -> String { 45 | let mut result = String::new(); 46 | let mut counter = 1; 47 | 48 | for c in query.chars() { 49 | if c == '?' { 50 | result.push_str(&format!("${counter}")); 51 | counter += 1; 52 | } else { 53 | result.push(c); 54 | } 55 | } 56 | 57 | result 58 | } 59 | 60 | /// Create a placeholder string (?, ?, ?) for a given count of placeholders, 61 | /// for one value 62 | #[inline] 63 | pub(crate) fn placeholders(count: usize) -> String { 64 | let str_placeholders = repeat("?".to_string()) 65 | .take(count) 66 | .collect::>() 67 | .join(", "); 68 | 69 | format!("({str_placeholders})") 70 | } 71 | 72 | /// Create a placeholder string (?, ?, ?), (?, ?, ?), (?, ?, ?) for a given 73 | /// count of placeholders, for n values 74 | #[inline] 75 | pub(crate) fn repeat_placeholders(count: usize, n_repeat: usize) -> String { 76 | repeat(placeholders(count)) 77 | .take(n_repeat) 78 | .collect::>() 79 | .join(", ") 80 | } 81 | 82 | /// Sanitize table and column names to avoid SQL injection 83 | /// Only letters, numbers and underscores are allowed. No spaces 84 | #[inline] 85 | pub(crate) fn sanitize_identifier(str: &str) -> String { 86 | str.replace(|c: char| !c.is_alphanumeric() && c != '_', "") 87 | } 88 | 89 | /// Generate an UPDATE statement from a table name and a list of keys 90 | #[inline] 91 | pub(crate) fn update_statement(table: &str, keys: &[String]) -> String { 92 | let table = sanitize_identifier(table); 93 | let columns = keys 94 | .iter() 95 | .map(|key| format!("\"{}\" = ?", sanitize_identifier(key))) 96 | .collect::>() 97 | .join(", "); 98 | 99 | format!("UPDATE {table} SET {columns} WHERE id = ? RETURNING *") 100 | } 101 | 102 | /// Generate an INSERT statement from a table name and a list of keys 103 | #[inline] 104 | pub(crate) fn insert_statement(table: &str, keys: &[String]) -> String { 105 | let table = sanitize_identifier(table); 106 | let values_placeholders = placeholders(keys.len()); 107 | let columns = format_iter(keys.iter().map(|s| sanitize_identifier(s)), ", "); 108 | 109 | format!("INSERT INTO {table} ({columns}) VALUES {values_placeholders} RETURNING *") 110 | } 111 | 112 | /// Generate an INSERT statement from a table name and a list of keys 113 | /// to insert multiple rows at once 114 | #[inline] 115 | pub(crate) fn insert_many_statement(table: &str, keys: &[String], n_rows: usize) -> String { 116 | let table = sanitize_identifier(table); 117 | let values_placeholders = repeat_placeholders(keys.len(), n_rows); 118 | let columns = format_iter(keys.iter().map(|s| sanitize_identifier(s)), ", "); 119 | 120 | format!("INSERT INTO {table} ({columns}) VALUES {values_placeholders} RETURNING *") 121 | } 122 | 123 | /// Generate a DELETE statement from a table name and an id 124 | #[inline] 125 | pub(crate) fn delete_statement(table: &str) -> String { 126 | let table = sanitize_identifier(table); 127 | 128 | format!("DELETE FROM {table} WHERE id = ? RETURNING *") 129 | } 130 | 131 | /// SQL-like implementation of the LIKE operator 132 | /// '_' matches any single character 133 | /// '%' matches zero or more characters 134 | pub(crate) fn sql_like(filter: &str, value: &str) -> bool { 135 | // Helper function to perform recursive pattern matching 136 | fn match_helper(f: &[char], v: &[char]) -> bool { 137 | match (f, v) { 138 | // If both filter and value are empty, it's a match 139 | ([], []) => true, 140 | 141 | // If filter has '%', it can match zero or more characters 142 | ([first, rest @ ..], value) if *first == '%' => { 143 | // Match zero characters or keep consuming value characters 144 | match_helper(rest, value) || (!value.is_empty() && match_helper(f, &value[1..])) 145 | } 146 | 147 | // If filter has '_', it matches exactly one character if value is not empty 148 | ([first, rest @ ..], [_, v_rest @ ..]) if *first == '_' => match_helper(rest, v_rest), 149 | 150 | // If the current characters of both filter and value match, proceed 151 | ([first, rest @ ..], [v_first, v_rest @ ..]) if first == v_first => { 152 | match_helper(rest, v_rest) 153 | } 154 | 155 | // If nothing matches, return false 156 | _ => false, 157 | } 158 | } 159 | 160 | // Convert both filter and value to character slices for easier handling 161 | match_helper( 162 | &filter.chars().collect::>(), 163 | &value.chars().collect::>(), 164 | ) 165 | } 166 | 167 | /// SQL-like implementation of the ILIKE operator 168 | pub(crate) fn sql_ilike(filter: &str, value: &str) -> bool { 169 | sql_like(&filter.to_lowercase(), &value.to_lowercase()) 170 | } 171 | 172 | #[cfg(test)] 173 | mod test_utils { 174 | use super::sql_like; 175 | 176 | #[test] 177 | /// The sql_like function was generated with ChatGPT 178 | /// This test guarantees that the function works as expected 179 | fn test_sql_like() { 180 | assert!(sql_like("he_lo", "hello")); 181 | assert!(sql_like("h%o", "hello")); 182 | assert!(!sql_like("h%o", "hi")); 183 | assert!(sql_like("%", "anything")); 184 | assert!(sql_like("_____", "12345")); 185 | assert!(sql_like("_%_", "abc")); 186 | assert!(sql_like("h_llo", "hello")); 187 | assert!(!sql_like("he_lo", "heeeelo")); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/queries.rs: -------------------------------------------------------------------------------- 1 | //! Query system for real-time SQLX 2 | 3 | use serialize::{Condition, Constraint, ConstraintValue, FinalType, Operator, QueryTree}; 4 | 5 | use crate::{ 6 | operations::serialize::JsonObject, 7 | utils::{sql_ilike, sql_like}, 8 | }; 9 | 10 | pub mod display; 11 | pub mod serialize; 12 | 13 | // ************************************************************************* // 14 | // QUERY SYSTEM IMPLEMENTATION // 15 | // ************************************************************************* // 16 | 17 | /// Comparing 2 final types 18 | impl FinalType { 19 | /// Compare self (left side) with another final type (right side) using an operator 20 | pub fn compare(&self, other: &FinalType, operator: &Operator) -> bool { 21 | match operator { 22 | Operator::Equal => self.equals(other), 23 | Operator::LessThan => self.less_than(other), 24 | Operator::GreaterThan => self.greater_than(other), 25 | Operator::LessThanOrEqual => self.less_than_or_equal(other), 26 | Operator::GreaterThanOrEqual => self.greater_than_or_equal(other), 27 | Operator::NotEqual => !self.equals(other), 28 | Operator::Like => match (self, other) { 29 | (FinalType::String(s), FinalType::String(t)) => sql_like(t, s), 30 | _ => false, 31 | }, 32 | Operator::ILike => match (self, other) { 33 | (FinalType::String(s), FinalType::String(t)) => sql_ilike(t, s), 34 | _ => false, 35 | }, 36 | _ => panic!("Invalid operator {} for comparison", operator), 37 | } 38 | } 39 | 40 | // Particularized implementations 41 | 42 | /// &self == other 43 | pub fn equals(&self, other: &FinalType) -> bool { 44 | match (self, other) { 45 | (FinalType::Number(n), FinalType::Number(m)) => { 46 | if n.is_f64() && m.is_f64() { 47 | n.as_f64().unwrap() == m.as_f64().unwrap() 48 | } else if n.is_i64() && m.is_i64() { 49 | n.as_i64().unwrap() == m.as_i64().unwrap() 50 | } else { 51 | false 52 | } 53 | } 54 | (FinalType::String(s), FinalType::String(t)) => s == t, 55 | (FinalType::Bool(b), FinalType::Bool(c)) => b == c, 56 | (FinalType::Null, FinalType::Null) => true, 57 | _ => false, 58 | } 59 | } 60 | 61 | /// &self < other 62 | pub fn less_than(&self, other: &FinalType) -> bool { 63 | match (self, other) { 64 | (FinalType::Number(n), FinalType::Number(m)) => { 65 | if n.is_f64() && m.is_f64() { 66 | n.as_f64().unwrap() < m.as_f64().unwrap() 67 | } else if n.is_i64() && m.is_i64() { 68 | n.as_i64().unwrap() < m.as_i64().unwrap() 69 | } else { 70 | false 71 | } 72 | } 73 | (FinalType::String(s), FinalType::String(t)) => s < t, 74 | (FinalType::Bool(b), FinalType::Bool(c)) => b < c, 75 | _ => false, 76 | } 77 | } 78 | 79 | /// &self > other 80 | pub fn greater_than(&self, other: &FinalType) -> bool { 81 | match (self, other) { 82 | (FinalType::Number(n), FinalType::Number(m)) => { 83 | if n.is_f64() && m.is_f64() { 84 | n.as_f64().unwrap() > m.as_f64().unwrap() 85 | } else if n.is_i64() && m.is_i64() { 86 | n.as_i64().unwrap() > m.as_i64().unwrap() 87 | } else { 88 | false 89 | } 90 | } 91 | (FinalType::String(s), FinalType::String(t)) => s > t, 92 | (FinalType::Bool(b), FinalType::Bool(c)) => b > c, 93 | _ => false, 94 | } 95 | } 96 | 97 | /// &self <= other 98 | pub fn less_than_or_equal(&self, other: &FinalType) -> bool { 99 | self.less_than(other) || self.equals(other) 100 | } 101 | 102 | /// &self >= other 103 | pub fn greater_than_or_equal(&self, other: &FinalType) -> bool { 104 | self.greater_than(other) || self.equals(other) 105 | } 106 | } 107 | 108 | impl ConstraintValue { 109 | /// Compare a constraint value with a final type (a constraint value can be a list of final types) 110 | /// NOTE : assume that the ConstraintValue is always on the right side of the comparison 111 | /// (for instance with the operator IN) 112 | pub fn compare(&self, other: &FinalType, operator: &Operator) -> bool { 113 | match self { 114 | ConstraintValue::Final(final_type) => final_type.compare(other, operator), 115 | ConstraintValue::List(list) => match operator { 116 | Operator::In => { 117 | for value in list { 118 | if value.compare(other, &Operator::Equal) { 119 | return true; 120 | } 121 | } 122 | false 123 | } 124 | _ => panic!("Invalid operator {} for list comparison", operator), 125 | }, 126 | } 127 | } 128 | } 129 | 130 | // ************************************************************************* // 131 | // CHECKS AGAINST JSON OBJECT // 132 | // ************************************************************************* // 133 | 134 | pub trait Checkable { 135 | fn check(&self, object: &JsonObject) -> bool; 136 | } 137 | 138 | impl Checkable for Constraint { 139 | /// Check if a constraint is satisfied by a JSON object 140 | fn check(&self, object: &JsonObject) -> bool { 141 | let value = object 142 | .get(&self.column) 143 | .expect("Column not found in JSON object"); 144 | 145 | let final_type = FinalType::try_from(value.clone()) 146 | .expect(format!("Incompatible value for column: {value}").as_str()); 147 | 148 | self.value.compare(&final_type, &self.operator) 149 | } 150 | } 151 | 152 | impl Checkable for Condition { 153 | /// Check if a condition is satisfied by a JSON object 154 | fn check(&self, object: &JsonObject) -> bool { 155 | match self { 156 | Condition::Single { constraint } => constraint.check(object), 157 | Condition::And { conditions } => { 158 | for condition in conditions { 159 | if !condition.check(object) { 160 | return false; 161 | } 162 | } 163 | true 164 | } 165 | Condition::Or { conditions } => { 166 | for condition in conditions { 167 | if condition.check(object) { 168 | return true; 169 | } 170 | } 171 | false 172 | } 173 | } 174 | } 175 | } 176 | 177 | impl Checkable for QueryTree { 178 | /// Check if a query is satisfied by a JSON object 179 | fn check(&self, object: &JsonObject) -> bool { 180 | if let Some(condition) = &self.condition { 181 | condition.check(object) 182 | } else { 183 | true 184 | } 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /packages/real-time-sqlx/src/types.ts: -------------------------------------------------------------------------------- 1 | /** Subscription serialized type declarations */ 2 | 3 | // ************************************************************************* // 4 | // CONSTRAINTS // 5 | // ************************************************************************* // 6 | 7 | export type FinalValue = string | number | boolean | null; 8 | 9 | /** Constraint value */ 10 | export type ConstraintValue = FinalValue | FinalValue[]; 11 | 12 | /** Constraint data */ 13 | export interface ConstraintSerialized { 14 | column: string; 15 | operator: QueryOperator; 16 | value: ConstraintValue; 17 | } 18 | 19 | // ************************************************************************* // 20 | // CONDITIONS // 21 | // ************************************************************************* // 22 | 23 | /** Condition operators */ 24 | export type QueryOperator = 25 | | "=" 26 | | "<" 27 | | ">" 28 | | "<=" 29 | | ">=" 30 | | "!=" 31 | | "in" 32 | | "like" 33 | | "ilike"; 34 | 35 | /** Condition type */ 36 | export enum ConditionType { 37 | Single = "single", // One single condition 38 | And = "and", // A list of conditions 39 | Or = "or", // A list of conditions 40 | } 41 | 42 | /** Condition data */ 43 | export type ConditionSerialized = 44 | | { 45 | type: ConditionType.Single; 46 | constraint: ConstraintSerialized; 47 | } 48 | | { 49 | type: ConditionType.And | ConditionType.Or; 50 | conditions: ConditionSerialized[]; 51 | }; 52 | 53 | // ************************************************************************* // 54 | // QUERIES // 55 | // ************************************************************************* // 56 | 57 | /** Order by options */ 58 | export interface OrderBy { 59 | column: keyof T; 60 | order: "asc" | "desc"; 61 | } 62 | 63 | /** Pagination options */ 64 | export interface PaginateOptions { 65 | perPage: number; 66 | offset?: number; 67 | orderBy?: OrderBy; 68 | } 69 | 70 | /** How many rows should be returned from the query */ 71 | export enum QueryReturnType { 72 | Single = "single", 73 | Many = "many", 74 | } 75 | 76 | /** Complete query data */ 77 | export interface SerializedQuery { 78 | return: QueryReturnType; 79 | table: string; 80 | condition: ConditionSerialized | null; 81 | paginate: PaginateOptions | null; 82 | } 83 | 84 | // ************************************************************************* // 85 | // DATA // 86 | // ************************************************************************* // 87 | 88 | export interface SingleQueryData { 89 | type: QueryReturnType.Single; 90 | data: T | null; 91 | } 92 | 93 | export interface ManyQueryData { 94 | type: QueryReturnType.Many; 95 | data: T[]; 96 | } 97 | 98 | /** Generic data return type */ 99 | export type QueryData = SingleQueryData | ManyQueryData; 100 | 101 | // ************************************************************************* // 102 | // UPDATES // 103 | // ************************************************************************* // 104 | 105 | /** Base type for table data which has an index */ 106 | export interface Indexable { 107 | id: FinalValue; 108 | } 109 | 110 | /** Indexable data update type (partial attributes without the id) */ 111 | export type UpdateData = Partial>; 112 | 113 | type MakeFieldOptional = Omit & Partial>; 114 | 115 | /** Indexable data creation type (all attributes with optional id) */ 116 | export type CreateData = MakeFieldOptional; 117 | 118 | /** Database operation type */ 119 | export enum OperationType { 120 | Create = "create", 121 | CreateMany = "create_many", 122 | Update = "update", 123 | Delete = "delete", 124 | } 125 | 126 | // ************************************************************************* // 127 | // GRANULAR UPDATE OPERATIONS // 128 | // ************************************************************************* // 129 | 130 | // Granular frontend operations 131 | // The frontend sends these operations to be done by the backend 132 | 133 | interface GranularOperationBase { 134 | type: OperationType; 135 | table: string; 136 | } 137 | 138 | /** Create an entry in a database table */ 139 | export interface GranularOperationCreate 140 | extends GranularOperationBase { 141 | type: OperationType.Create; 142 | data: CreateData; 143 | } 144 | 145 | /** Create many entries in a database table */ 146 | export interface GranularOperationCreateMany 147 | extends GranularOperationBase { 148 | type: OperationType.CreateMany; 149 | data: CreateData[]; 150 | } 151 | 152 | /** Update an entry in a database table */ 153 | export interface GranularOperationUpdate 154 | extends GranularOperationBase { 155 | type: OperationType.Update; 156 | id: FinalValue; 157 | data: UpdateData; 158 | } 159 | 160 | /** Delete an entry in a database table */ 161 | export interface GranularOperationDelete extends GranularOperationBase { 162 | type: OperationType.Delete; 163 | id: FinalValue; 164 | } 165 | 166 | /** Granular database operation (used in the frontend) */ 167 | export type GranularOperation = 168 | | GranularOperationCreate 169 | | GranularOperationCreateMany 170 | | GranularOperationUpdate 171 | | GranularOperationDelete; 172 | 173 | // ************************************************************************* // 174 | // OPERATION NOTIFICATIONS // 175 | // ************************************************************************* // 176 | 177 | // Complete backend operations 178 | // The backend sends back these operation results to be processed by the frontend 179 | 180 | interface OperationNotificationBase { 181 | type: OperationType; 182 | table: string; 183 | } 184 | 185 | /** Notification of entry creation */ 186 | export interface OperationNotificationCreate 187 | extends OperationNotificationBase { 188 | type: OperationType.Create; 189 | data: T; // The full data with ID is sent back 190 | } 191 | 192 | /** Notification of multiple entries creation */ 193 | export interface OperationNotificationCreateMany 194 | extends OperationNotificationBase { 195 | type: OperationType.CreateMany; 196 | data: T[]; // The full data with ID is sent back 197 | } 198 | 199 | /** Notification of entry update */ 200 | export interface OperationNotificationUpdate 201 | extends OperationNotificationBase { 202 | type: OperationType.Update; 203 | id: FinalValue; 204 | data: T; // The full data with ID is sent back 205 | } 206 | 207 | /** Notification of entry deletion */ 208 | export interface OperationNotificationDelete 209 | extends OperationNotificationBase { 210 | type: OperationType.Delete; 211 | id: FinalValue; 212 | data: T; 213 | } 214 | 215 | /** Notification of database operation (returned by the backend) */ 216 | export type OperationNotification = 217 | | OperationNotificationCreate 218 | | OperationNotificationCreateMany 219 | | OperationNotificationUpdate 220 | | OperationNotificationDelete; 221 | -------------------------------------------------------------------------------- /packages/real-time-sqlx/src/builders.ts: -------------------------------------------------------------------------------- 1 | /** Query builders for the real-time subscription engine. */ 2 | 3 | import { invoke } from "@tauri-apps/api/core"; 4 | import { 5 | Condition, 6 | ConditionAnd, 7 | ConditionNone, 8 | ConditionOr, 9 | ConditionSingle, 10 | } from "./conditions"; 11 | import type { 12 | FetchMoreFn, 13 | UnsubscribeFn, 14 | UpdateManyFn, 15 | UpdateSingleFn, 16 | } from "./subscribe"; 17 | import { subscribeMany, subscribeOne } from "./subscribe"; 18 | import { 19 | QueryReturnType, 20 | type FinalValue, 21 | type Indexable, 22 | type ManyQueryData, 23 | type PaginateOptions, 24 | type QueryOperator, 25 | type SerializedQuery, 26 | type SingleQueryData, 27 | } from "./types"; 28 | import { paginate } from "./paginate"; 29 | 30 | /** Callback to create nested queries */ 31 | export type QueryCallback = ( 32 | query: InitialQueryBuilder, 33 | ) => BaseQueryBuilder; 34 | 35 | /** Base class for query builders that declares shared data and methods. */ 36 | export class BaseQueryBuilder { 37 | constructor( 38 | protected table: string, 39 | protected condition: Condition, 40 | ) {} 41 | 42 | /** Fetch the first matching row */ 43 | async fetchOne(options?: PaginateOptions): Promise> { 44 | const query: SerializedQuery = { 45 | return: QueryReturnType.Single, 46 | table: this.table, 47 | condition: 48 | this.condition instanceof ConditionNone 49 | ? null 50 | : this.condition.toJSON(), 51 | paginate: options ?? null, 52 | }; 53 | return await invoke("fetch", { query }); 54 | } 55 | 56 | /** Fetch all matching rows */ 57 | async fetchMany(options?: PaginateOptions): Promise> { 58 | const query: SerializedQuery = { 59 | return: QueryReturnType.Many, 60 | table: this.table, 61 | condition: 62 | this.condition instanceof ConditionNone 63 | ? null 64 | : this.condition.toJSON(), 65 | paginate: options ?? null, 66 | }; 67 | return await invoke("fetch", { query }); 68 | } 69 | 70 | /** Subscribe to the first matching row */ 71 | subscribeOne(callback: UpdateSingleFn): UnsubscribeFn { 72 | return subscribeOne(this.table, this.condition, callback); 73 | } 74 | 75 | /** Subscribe to all matching rows */ 76 | subscribeMany(callback: UpdateManyFn): UnsubscribeFn { 77 | return subscribeMany(this.table, this.condition, callback); 78 | } 79 | 80 | /** Subscribe to a paginated query */ 81 | paginate( 82 | options: PaginateOptions, 83 | callback: UpdateManyFn, 84 | ): [UnsubscribeFn, FetchMoreFn] { 85 | return paginate(this.table, this.condition, options, callback); 86 | } 87 | 88 | /** Condition accessor for internal use. */ 89 | getCondition(): Condition { 90 | return this.condition; 91 | } 92 | } 93 | 94 | /** Empty query builder with no conditions */ 95 | export class InitialQueryBuilder< 96 | T extends Indexable, 97 | > extends BaseQueryBuilder { 98 | constructor(table: string) { 99 | super(table, new ConditionNone()); 100 | } 101 | 102 | /** Add a single constraint to the query */ 103 | where( 104 | column: C, 105 | operator: O, 106 | value: O extends "in" ? (T[C] & FinalValue)[] : T[C] & FinalValue, 107 | ): QueryBuilderWithCondition { 108 | return new QueryBuilderWithCondition( 109 | this.table, 110 | new ConditionSingle({ column, operator, value }), 111 | ); 112 | } 113 | 114 | /** Add a nested condition to the query */ 115 | whereCallback(callback: QueryCallback): QueryBuilderWithCondition { 116 | const builder = query(this.table); 117 | return new QueryBuilderWithCondition( 118 | this.table, 119 | callback(builder).getCondition(), 120 | ); 121 | } 122 | } 123 | 124 | /** Query builder with a single condition */ 125 | class QueryBuilderWithCondition< 126 | T extends Indexable, 127 | > extends BaseQueryBuilder { 128 | constructor( 129 | table: string, 130 | protected condition: Condition, 131 | ) { 132 | super(table, condition); 133 | } 134 | 135 | /** Add a new joint condition to the query */ 136 | and( 137 | column: C, 138 | operator: O, 139 | value: O extends "in" ? (T[C] & FinalValue)[] : T[C] & FinalValue, 140 | ): QueryBuilderWithAndCondition { 141 | return new QueryBuilderWithAndCondition( 142 | this.table, 143 | this.condition.and({ column, operator, value }), 144 | ); 145 | } 146 | 147 | /** Add a new alternative condition to the query */ 148 | or( 149 | column: C, 150 | operator: O, 151 | value: O extends "in" ? (T[C] & FinalValue)[] : T[C] & FinalValue, 152 | ): QueryBuilderWithOrCondition { 153 | return new QueryBuilderWithOrCondition( 154 | this.table, 155 | this.condition.or({ column, operator, value }), 156 | ); 157 | } 158 | 159 | /** Add a new nested joint condition to the query */ 160 | andCallback(callback: QueryCallback): QueryBuilderWithAndCondition { 161 | const builder = query(this.table); 162 | const result = callback(builder); 163 | 164 | return QueryBuilderWithAndCondition.fromConditions(this.table, [ 165 | this.condition, 166 | result.getCondition(), 167 | ]); 168 | } 169 | 170 | /** Add a new nested alternative condition to the query */ 171 | orCallback(callback: QueryCallback): QueryBuilderWithOrCondition { 172 | const builder = query(this.table); 173 | const result = callback(builder); 174 | 175 | return QueryBuilderWithOrCondition.fromConditions(this.table, [ 176 | this.condition, 177 | result.getCondition(), 178 | ]); 179 | } 180 | } 181 | 182 | /** Query builder with joint conditions */ 183 | class QueryBuilderWithAndCondition< 184 | T extends Indexable, 185 | > extends BaseQueryBuilder { 186 | constructor( 187 | table: string, 188 | protected condition: ConditionAnd, 189 | ) { 190 | super(table, condition); 191 | } 192 | 193 | /** Add a new joint condition to the query */ 194 | and( 195 | column: C, 196 | operator: O, 197 | value: O extends "in" ? (T[C] & FinalValue)[] : T[C] & FinalValue, 198 | ): QueryBuilderWithAndCondition { 199 | // Push a new ConstraintSingle to the list of conditions 200 | this.condition.conditions.push( 201 | Condition.fromConstraint({ column, operator, value }), 202 | ); 203 | return this; 204 | } 205 | 206 | /** Add a new nested joint condition to the query */ 207 | andCallback(callback: QueryCallback): QueryBuilderWithAndCondition { 208 | const builder = query(this.table); 209 | const result = callback(builder); 210 | this.condition.conditions.push(result.getCondition()); 211 | 212 | return this; 213 | } 214 | 215 | /** Create a new query builder from a list of conditions */ 216 | static fromConditions( 217 | table: string, 218 | conditions: Condition[], 219 | ) { 220 | return new QueryBuilderWithAndCondition( 221 | table, 222 | new ConditionAnd(conditions), 223 | ); 224 | } 225 | } 226 | 227 | /** Query builder with alternative conditions */ 228 | class QueryBuilderWithOrCondition< 229 | T extends Indexable, 230 | > extends BaseQueryBuilder { 231 | constructor( 232 | table: string, 233 | protected condition: ConditionOr, 234 | ) { 235 | super(table, condition); 236 | } 237 | 238 | /** Add a new alternative condition to the query */ 239 | or( 240 | column: C, 241 | operator: O, 242 | value: O extends "in" ? (T[C] & FinalValue)[] : T[C] & FinalValue, 243 | ): QueryBuilderWithOrCondition { 244 | this.condition.conditions.push( 245 | Condition.fromConstraint({ column, operator, value }), 246 | ); 247 | return this; 248 | } 249 | 250 | /** Add a new nested alternative condition to the query */ 251 | orCallback(callback: QueryCallback): QueryBuilderWithOrCondition { 252 | const builder = query(this.table); 253 | const result = callback(builder); 254 | this.condition.conditions.push(result.getCondition()); 255 | 256 | return this; 257 | } 258 | 259 | /** Create a new query builder from a list of conditions */ 260 | static fromConditions( 261 | table: string, 262 | conditions: Condition[], 263 | ) { 264 | return new QueryBuilderWithOrCondition( 265 | table, 266 | new ConditionOr(conditions), 267 | ); 268 | } 269 | } 270 | 271 | /** Create a new query on a table. 272 | * Duplicated here but not exported, 273 | * without type checking for internal use. 274 | */ 275 | const query = (table: string): InitialQueryBuilder => 276 | new InitialQueryBuilder(table); 277 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/tests/queries.rs: -------------------------------------------------------------------------------- 1 | //! Serialized queries tests 2 | 3 | use sqlx::FromRow; 4 | use std::{fs, path::Path}; 5 | 6 | use crate::database::sqlite::fetch_sqlite_query; 7 | use crate::queries::serialize::{QueryData, QueryTree}; 8 | use crate::tests::dummy::{dummy_sqlite_database, prepare_dummy_sqlite_database}; 9 | 10 | use super::dummy::Todo; 11 | use super::utils::read_serialized_query; 12 | 13 | #[tokio::test] 14 | async fn test_deserialize_queries() { 15 | // Get the queries 16 | let queries_path = Path::new("src/tests/queries"); 17 | 18 | for entry in fs::read_dir(queries_path).unwrap() { 19 | let entry = entry.unwrap(); 20 | 21 | let serialized_query = fs::read_to_string(entry.path()).unwrap(); 22 | 23 | // Deserialize the query from json 24 | let query: serde_json::Value = serde_json::from_str(&serialized_query).unwrap(); 25 | serde_json::from_value::(query).expect(&format!( 26 | "Failed to deserialize query: {}", 27 | entry.file_name().into_string().unwrap() 28 | )); 29 | } 30 | } 31 | 32 | // ************************************************************************* // 33 | // TESTING AGAINST SQLITE BACKEND // 34 | // ************************************************************************* // 35 | 36 | /// Test single row fetching 37 | #[tokio::test] 38 | async fn test_sqlite_single() { 39 | let pool = dummy_sqlite_database().await; 40 | prepare_dummy_sqlite_database(&pool).await; 41 | 42 | let query = read_serialized_query("01_single.json"); 43 | let result = fetch_sqlite_query(&query, &pool).await; 44 | 45 | match result { 46 | QueryData::Single(row) => { 47 | let single_row = row.expect("Expected a single row"); 48 | 49 | let data = Todo::from_row(&single_row).expect("Failed to convert single row"); 50 | assert_eq!(data.id, 1); 51 | assert_eq!(data.title, "First todo"); 52 | assert_eq!(data.content, "This is the first todo"); 53 | } 54 | QueryData::Many(_) => panic!("Expected a single row"), 55 | } 56 | } 57 | 58 | /// Test many row fetching 59 | #[tokio::test] 60 | async fn test_sqlite_many() { 61 | let pool = dummy_sqlite_database().await; 62 | prepare_dummy_sqlite_database(&pool).await; 63 | 64 | let query = read_serialized_query("02_many.json"); 65 | let result = fetch_sqlite_query(&query, &pool).await; 66 | 67 | match result { 68 | QueryData::Single(_) => { 69 | panic!("Expected many rows") 70 | } 71 | QueryData::Many(rows) => { 72 | assert_eq!(rows.len(), 3); 73 | 74 | let first_row = Todo::from_row(&rows[0]).expect("Failed to convert first row"); 75 | assert_eq!(first_row.id, 1); 76 | assert_eq!(first_row.title, "First todo"); 77 | assert_eq!(first_row.content, "This is the first todo"); 78 | 79 | let second_row = Todo::from_row(&rows[1]).expect("Failed to convert second row"); 80 | assert_eq!(second_row.id, 2); 81 | assert_eq!(second_row.title, "Second todo"); 82 | assert_eq!(second_row.content, "This is the second todo"); 83 | 84 | let third_row = Todo::from_row(&rows[2]).expect("Failed to convert third row"); 85 | assert_eq!(third_row.id, 3); 86 | assert_eq!(third_row.title, "Third todo"); 87 | assert_eq!(third_row.content, "This is the third todo"); 88 | } 89 | } 90 | } 91 | 92 | /// Test single row fetching with a condition 93 | #[tokio::test] 94 | async fn test_sqlite_single_with_condition() { 95 | let pool = dummy_sqlite_database().await; 96 | prepare_dummy_sqlite_database(&pool).await; 97 | 98 | let query = read_serialized_query("03_single_with_condition.json"); 99 | let result = fetch_sqlite_query(&query, &pool).await; 100 | 101 | match result { 102 | QueryData::Single(row) => { 103 | let single_row = row.expect("Expected a single row"); 104 | 105 | let data = Todo::from_row(&single_row).expect("Failed to convert single row"); 106 | assert_eq!(data.id, 2); 107 | assert_eq!(data.title, "Second todo"); 108 | assert_eq!(data.content, "This is the second todo"); 109 | } 110 | QueryData::Many(_) => panic!("Expected a single row"), 111 | } 112 | } 113 | 114 | /// Test many row fetching with a condition returning a single row 115 | #[tokio::test] 116 | async fn test_sqlite_many_with_condition() { 117 | let pool = dummy_sqlite_database().await; 118 | prepare_dummy_sqlite_database(&pool).await; 119 | 120 | let query = read_serialized_query("04_many_with_condition.json"); 121 | let result = fetch_sqlite_query(&query, &pool).await; 122 | 123 | match result { 124 | QueryData::Single(_) => { 125 | panic!("Expected many rows") 126 | } 127 | QueryData::Many(rows) => { 128 | assert_eq!(rows.len(), 1); 129 | 130 | let data = Todo::from_row(&rows[0]).expect("Failed to convert first row"); 131 | assert_eq!(data.id, 2); 132 | assert_eq!(data.title, "Second todo"); 133 | assert_eq!(data.content, "This is the second todo"); 134 | } 135 | } 136 | } 137 | 138 | /// Test fetching many rows with a nested OR condition 139 | #[tokio::test] 140 | async fn test_sqlite_nested_or() { 141 | let pool = dummy_sqlite_database().await; 142 | prepare_dummy_sqlite_database(&pool).await; 143 | 144 | let query = read_serialized_query("05_nested_or.json"); 145 | let result = fetch_sqlite_query(&query, &pool).await; 146 | 147 | match result { 148 | QueryData::Single(_) => { 149 | panic!("Expected many rows") 150 | } 151 | QueryData::Many(rows) => { 152 | assert_eq!(rows.len(), 3); 153 | } 154 | } 155 | } 156 | 157 | /// Test single row fetching with no existing matching entry 158 | #[tokio::test] 159 | async fn test_sqlite_empty() { 160 | let pool = dummy_sqlite_database().await; 161 | prepare_dummy_sqlite_database(&pool).await; 162 | 163 | let query = read_serialized_query("06_empty.json"); 164 | let result = fetch_sqlite_query(&query, &pool).await; 165 | 166 | match result { 167 | QueryData::Single(row) => { 168 | assert!(row.is_none()); 169 | } 170 | QueryData::Many(_) => panic!("Expected a single row"), 171 | } 172 | } 173 | 174 | /// Test `IN` operations with arrays 175 | #[tokio::test] 176 | async fn test_sqlite_in() { 177 | let pool = dummy_sqlite_database().await; 178 | prepare_dummy_sqlite_database(&pool).await; 179 | 180 | let query = read_serialized_query("07_in.json"); 181 | let result = fetch_sqlite_query(&query, &pool).await; 182 | 183 | match result { 184 | QueryData::Single(_) => { 185 | panic!("Expected many rows") 186 | } 187 | QueryData::Many(rows) => { 188 | assert_eq!(rows.len(), 2); 189 | 190 | let first_row = Todo::from_row(&rows[0]).expect("Failed to convert first row"); 191 | assert_eq!(first_row.id, 1); 192 | assert_eq!(first_row.title, "First todo"); 193 | assert_eq!(first_row.content, "This is the first todo"); 194 | 195 | let second_row = Todo::from_row(&rows[1]).expect("Failed to convert second row"); 196 | assert_eq!(second_row.id, 3); 197 | assert_eq!(second_row.title, "Third todo"); 198 | assert_eq!(second_row.content, "This is the third todo"); 199 | } 200 | } 201 | } 202 | 203 | /// Test paginated single row queries 204 | #[tokio::test] 205 | async fn test_sqlite_paginated_single() { 206 | let pool = dummy_sqlite_database().await; 207 | prepare_dummy_sqlite_database(&pool).await; 208 | 209 | let query = read_serialized_query("08_paginated_single.json"); 210 | let result = fetch_sqlite_query(&query, &pool).await; 211 | 212 | match result { 213 | QueryData::Single(row) => { 214 | assert!(row.is_some()); 215 | 216 | let row = Todo::from_row(&row.unwrap()).expect("Failed to convert row"); 217 | 218 | assert_eq!(row.id, 2); 219 | assert_eq!(row.title, "Second todo"); 220 | assert_eq!(row.content, "This is the second todo"); 221 | } 222 | QueryData::Many(_) => { 223 | panic!("Expected one single row") 224 | } 225 | } 226 | } 227 | 228 | /// Test paginated multi row queries 229 | #[tokio::test] 230 | async fn test_sqlite_paginated_many() { 231 | let pool = dummy_sqlite_database().await; 232 | prepare_dummy_sqlite_database(&pool).await; 233 | 234 | let query = read_serialized_query("09_paginated_many.json"); 235 | let result = fetch_sqlite_query(&query, &pool).await; 236 | 237 | match result { 238 | QueryData::Single(_) => { 239 | panic!("Expected many rows") 240 | } 241 | QueryData::Many(rows) => { 242 | assert_eq!(rows.len(), 1); 243 | 244 | let row = Todo::from_row(&rows[0]).expect("Failed to convert row"); 245 | 246 | assert_eq!(row.id, 2); 247 | assert_eq!(row.title, "Second todo"); 248 | assert_eq!(row.content, "This is the second todo"); 249 | } 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/backends/tauri/macros.rs: -------------------------------------------------------------------------------- 1 | //! Tauri-related macros 2 | 3 | /// Main macro: 4 | /// - Generate the real-time static dispatcher struct that handles channels subscriptions 5 | /// - Generate the tauri commands for the "fetch", "subscribe", "unsubscribe", "execute". 6 | /// 7 | /// It should not be used in the lib.rs Tauri entrypoint. 8 | #[macro_export] 9 | macro_rules! real_time_tauri { 10 | ($db_type:ident, $(($table_name:literal, $struct:ty)),+ $(,)?) => { 11 | 12 | // Generate the real-time dispatcher struct 13 | $crate::real_time_dispatcher!($db_type, $(($table_name, $struct)),+); 14 | 15 | // Generate the function to statically serialize rows 16 | $crate::serialize_rows_static!(sqlite, ("todos", Todo), ("again", Todo)); 17 | 18 | // Tauri endpoints 19 | /// Subscribe to a real-time query 20 | #[tauri::command] 21 | pub async fn subscribe( 22 | // Managed by Tauri 23 | pool: tauri::State<'_, $crate::database_pool!($db_type)>, 24 | dispatcher: tauri::State<'_, RealTimeDispatcher>, 25 | // Passed as arguments 26 | query: $crate::queries::serialize::QueryTree, 27 | channel_id: String, 28 | channel: tauri::ipc::Channel, 29 | ) -> tauri::Result { 30 | let pool: &$crate::database_pool!($db_type) = &pool; 31 | 32 | // Process the immediate query value to be returned 33 | let rows = $crate::database::$db_type::fetch_sqlite_query(&query, pool).await; 34 | let value = serialize_rows_static(&rows, &query.table); 35 | 36 | // Add the channel to the dispatcher 37 | dispatcher 38 | .subscribe_channel(&query.table.clone(), &channel_id, query, channel) 39 | .await; 40 | 41 | Ok(value) 42 | } 43 | 44 | /// Unsubscribe from a real-time query 45 | #[tauri::command] 46 | pub async fn unsubscribe( 47 | // Managed by Tauri 48 | dispatcher: tauri::State<'_, RealTimeDispatcher>, 49 | // Passed as arguments 50 | channel_id: String, 51 | table: String, 52 | ) -> tauri::Result<()> { 53 | dispatcher.unsubscribe_channel(&table, &channel_id).await; 54 | 55 | Ok(()) 56 | } 57 | 58 | /// Execute a tauri granular operation 59 | #[tauri::command] 60 | pub async fn execute( 61 | // Managed by Tauri 62 | pool: tauri::State<'_, $crate::database_pool!($db_type)>, 63 | dispatcher: tauri::State<'_, RealTimeDispatcher>, 64 | // Passed as arguments 65 | operation: $crate::operations::serialize::GranularOperation, 66 | ) -> tauri::Result { 67 | let pool: &$crate::database_pool!($db_type) = &pool; 68 | let serialized_notification = dispatcher.process_operation(operation, pool).await; 69 | 70 | Ok(serialized_notification) 71 | } 72 | 73 | /// Fetch a query once (without subscription) 74 | #[tauri::command] 75 | pub async fn fetch( 76 | // Managed by Tauri 77 | pool: tauri::State<'_, $crate::database_pool!($db_type)>, 78 | // Passed as arguments 79 | query: $crate::queries::serialize::QueryTree, 80 | ) -> tauri::Result { 81 | let pool: &$crate::database_pool!($db_type) = &pool; 82 | 83 | let rows = $crate::database::$db_type::fetch_sqlite_query(&query, pool).await; 84 | let value = serialize_rows_static(&rows, &query.table); 85 | 86 | Ok(value) 87 | } 88 | 89 | /// Execute a raw SQL query with prepared statements 90 | #[tauri::command] 91 | pub async fn raw( 92 | // Managed by Tauri 93 | pool: tauri::State<'_, $crate::database_pool!($db_type)>, 94 | // Passed as arguments 95 | sql: String, 96 | values: Vec<$crate::queries::serialize::FinalType>, 97 | ) -> tauri::Result { 98 | let pool: &$crate::database_pool!($db_type) = &pool; 99 | 100 | let mut query = sqlx::query(&sql); 101 | 102 | $crate::macros::paste::paste! { 103 | for value in values { 104 | query = $crate::database::$db_type::[](query, value); 105 | } 106 | let rows = query.fetch_all(pool).await.unwrap(); 107 | let serialized_rows = $crate::database::$db_type::[<$db_type _rows_to_json>](&rows); 108 | } 109 | 110 | Ok(serialized_rows) 111 | } 112 | }; 113 | } 114 | 115 | /// Generate a real-time static dispatcher struct that can handle subscription channels for 116 | /// different tables. It processes granular operations and updates the channels accordingly. 117 | #[macro_export] 118 | macro_rules! real_time_dispatcher { 119 | ($db_type:ident, $(($table_name:literal, $struct:ty)),+ $(,)?) => { 120 | $crate::macros::paste::paste! { 121 | /// Real-time static channel dispatcher for the Tauri backend 122 | pub struct RealTimeDispatcher { 123 | // Define allRwLocked channels for the given tables 124 | $( 125 | pub [<$table_name _channels>]: tokio::sync::RwLock), std::hash::RandomState>>, 126 | )+ 127 | } 128 | } 129 | 130 | $crate::macros::paste::paste! { 131 | impl RealTimeDispatcher { 132 | /// Implement the generic handler function for all tables and channels. 133 | /// Returns a serialized operation notification option. 134 | pub async fn process_operation( 135 | &self, 136 | operation: $crate::operations::serialize::GranularOperation, 137 | pool: &$crate::database_pool!($db_type), 138 | ) -> serde_json::Value { 139 | use $crate::operations::serialize::Tabled; 140 | match operation.get_table() { 141 | $( 142 | $table_name => { 143 | // 1. Process the operation and obtain an operation notification 144 | let result: Option<$crate::operations::serialize::OperationNotification<$struct>> = 145 | $crate::granular_operation_fn!($db_type)(operation, pool).await; 146 | 147 | if let Some(result) = result { 148 | // 2. Process the operation notification and update the channels 149 | $crate::backends::tauri::channels::process_event_and_update_channels( 150 | &self.[<$table_name _channels>], 151 | &result, 152 | ).await; 153 | return serde_json::to_value(Some(result)).unwrap(); 154 | } 155 | 156 | serde_json::Value::Null 157 | } 158 | )+ 159 | _ => panic!("Table not found"), 160 | } 161 | } 162 | 163 | /// Unsubscribe a channel from the dispatcher 164 | pub async fn unsubscribe_channel(&self, table: &str, channel_id: &str) { 165 | match table { 166 | $( 167 | $table_name => { 168 | let mut channels = self.[<$table_name _channels>].write().await; 169 | channels.remove(channel_id); 170 | } 171 | )+ 172 | _ => panic!("Table not found"), 173 | } 174 | } 175 | 176 | /// Subscribe a channel to the dispatcher 177 | pub async fn subscribe_channel( 178 | &self, 179 | table: &str, 180 | channel_id: &str, 181 | query: $crate::queries::serialize::QueryTree, 182 | channel: tauri::ipc::Channel, 183 | ) { 184 | match table { 185 | $( 186 | $table_name => { 187 | let mut channels = self.[<$table_name _channels>].write().await; 188 | channels.insert(channel_id.to_string(), (query, channel)); 189 | } 190 | )+ 191 | _ => panic!("Table not found"), 192 | } 193 | } 194 | 195 | /// Create a new instance of the dispatcher 196 | pub fn new() -> Self { 197 | RealTimeDispatcher { 198 | $( 199 | [<$table_name _channels>]: tokio::sync::RwLock::new(std::collections::HashMap::new()), 200 | )+ 201 | } 202 | } 203 | } 204 | } 205 | }; 206 | } 207 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/database/mysql.rs: -------------------------------------------------------------------------------- 1 | //! Particularized MySQL implementations. 2 | 3 | use sqlx::{ 4 | mysql::{MySqlArguments, MySqlRow}, 5 | query::Query, 6 | Column, Executor, FromRow, MySql, Row, TypeInfo, 7 | }; 8 | 9 | use crate::{ 10 | operations::serialize::{GranularOperation, OperationNotification}, 11 | queries::serialize::{FinalType, QueryData, QueryTree, ReturnType}, 12 | utils::{ 13 | delete_statement, insert_many_statement, insert_statement, ordered_keys, update_statement, 14 | }, 15 | }; 16 | 17 | use super::prepare_sqlx_query; 18 | 19 | /// Bind a native value to a MySQL query 20 | #[inline] 21 | pub fn bind_mysql_value<'q>( 22 | query: Query<'q, MySql, MySqlArguments>, 23 | value: FinalType, 24 | ) -> Query<'q, MySql, MySqlArguments> { 25 | match value { 26 | FinalType::Null => query.bind(None::), 27 | FinalType::Number(number) => { 28 | if number.is_f64() { 29 | query.bind(number.as_f64().unwrap()) 30 | } else { 31 | query.bind(number.as_i64().unwrap()) 32 | } 33 | } 34 | FinalType::String(string) => query.bind(string), 35 | FinalType::Bool(bool) => query.bind(bool), 36 | } 37 | } 38 | 39 | /// Fetch data using a serialized query tree from a MySQL database 40 | pub async fn fetch_mysql_query<'a, E>(query: &QueryTree, executor: E) -> QueryData 41 | where 42 | E: Executor<'a, Database = MySql>, 43 | { 44 | // Prepare the query 45 | let (sql, values) = prepare_sqlx_query(&query); 46 | 47 | let mut sqlx_query = sqlx::query(&sql); 48 | 49 | // Bind the values 50 | for value in values { 51 | sqlx_query = bind_mysql_value(sqlx_query, value); 52 | } 53 | 54 | // Fetch one or many rows depending on the query 55 | match query.return_type { 56 | ReturnType::Single => { 57 | let row = sqlx_query.fetch_optional(executor).await.unwrap(); 58 | return QueryData::Single(row); 59 | } 60 | ReturnType::Many => { 61 | let rows = sqlx_query.fetch_all(executor).await.unwrap(); 62 | return QueryData::Many(rows); 63 | } 64 | } 65 | } 66 | 67 | /// Convert a MySQL row to a JSON object 68 | pub fn mysql_row_to_json(row: &MySqlRow) -> serde_json::Value { 69 | let mut json_map = serde_json::Map::new(); 70 | 71 | for column in row.columns() { 72 | let column_name = column.name(); 73 | let column_type = column.type_info().name(); 74 | 75 | // Dynamically match the type and insert it into the JSON map 76 | let value = match column_type { 77 | "INTEGER" => row 78 | .try_get::(column_name) 79 | .ok() 80 | .map(serde_json::Value::from), 81 | "REAL" | "NUMERIC" => row 82 | .try_get::(column_name) 83 | .ok() 84 | .map(serde_json::Value::from), 85 | "BOOLEAN" => row 86 | .try_get::(column_name) 87 | .ok() 88 | .map(serde_json::Value::from), 89 | "TEXT" | "DATE" | "TIME" | "DATETIME" => row 90 | .try_get::(column_name) 91 | .ok() 92 | .map(serde_json::Value::from), 93 | "NULL" => Some(serde_json::Value::Null), 94 | "BLOB" => None, // Skip BLOB columns 95 | _ => None, // Handle other types as needed 96 | }; 97 | 98 | // Add to JSON map if value is present 99 | if let Some(v) = value { 100 | json_map.insert(column_name.to_string(), v); 101 | } else { 102 | json_map.insert(column_name.to_string(), serde_json::Value::Null); 103 | } 104 | } 105 | 106 | serde_json::Value::Object(json_map) 107 | } 108 | 109 | /// Convert a vector of MySQL rows to a JSON array 110 | pub fn mysql_rows_to_json(rows: &[MySqlRow]) -> serde_json::Value { 111 | let mut json_array = Vec::new(); 112 | 113 | for row in rows { 114 | json_array.push(mysql_row_to_json(row)); 115 | } 116 | 117 | serde_json::Value::Array(json_array) 118 | } 119 | 120 | /// Helper function signature for serializing MySQL rows to JSON 121 | /// by mapping them to different data structs implementing `FromRow` 122 | /// and `Serialize` depending on the table name. 123 | pub type SerializeRowsMapped = fn(&QueryData, table: &str) -> serde_json::Value; 124 | 125 | /// Perform a granular operation on a MySQL database. 126 | /// Returns a notification to be sent to clients. 127 | pub async fn granular_operation_mysql<'a, E, T>( 128 | operation: GranularOperation, 129 | executor: E, 130 | ) -> Option> 131 | where 132 | E: Executor<'a, Database = MySql>, 133 | T: for<'r> FromRow<'r, MySqlRow>, 134 | { 135 | match operation { 136 | GranularOperation::Create { table, mut data } => { 137 | // Fix the order of the keys for later iterations 138 | let keys = ordered_keys(&data); 139 | 140 | // Produce the SQL query string 141 | let string_query = insert_statement(&table, &keys); 142 | let mut sqlx_query = sqlx::query(&string_query); 143 | 144 | // Bind the values in the order of the keys 145 | for key in keys.iter() { 146 | // Consume the value and convert it to a NativeType for proper binding 147 | let value = data.remove(key).unwrap(); 148 | let native_value = FinalType::try_from(value).unwrap(); 149 | sqlx_query = bind_mysql_value(sqlx_query, native_value); 150 | } 151 | 152 | let result = sqlx_query.fetch_one(executor).await.unwrap(); 153 | let data = T::from_row(&result).unwrap(); 154 | 155 | // Produce the creation notification 156 | Some(OperationNotification::Create { 157 | table: table.to_string(), 158 | data, 159 | }) 160 | } 161 | GranularOperation::CreateMany { table, mut data } => { 162 | // Fix the order of the keys for later iterations 163 | let keys = ordered_keys(&data[0]); 164 | 165 | // Produce the SQL query string 166 | let string_query = insert_many_statement(&table, &keys, data.len()); 167 | let mut sqlx_query = sqlx::query(&string_query); 168 | 169 | // Bind all values in order of the keys 170 | for entry in data.iter_mut() { 171 | for key in keys.iter() { 172 | // Consume the value and convert it to a NativeType for proper binding 173 | let value = entry.remove(key).unwrap(); 174 | let native_value = FinalType::try_from(value).unwrap(); 175 | sqlx_query = bind_mysql_value(sqlx_query, native_value); 176 | } 177 | } 178 | 179 | let results = sqlx_query.fetch_all(executor).await.unwrap(); 180 | let data: Vec = results 181 | .into_iter() 182 | .map(|row| T::from_row(&row).unwrap()) 183 | .collect(); 184 | 185 | // Produce the operation notification 186 | Some(OperationNotification::CreateMany { 187 | table: table.to_string(), 188 | data, 189 | }) 190 | } 191 | GranularOperation::Update { 192 | table, 193 | id, 194 | mut data, 195 | } => { 196 | // Fix the order of the keys for later iterations 197 | let keys = ordered_keys(&data); 198 | 199 | // Produce the SQL query string 200 | let string_query = update_statement(&table, &keys); 201 | let mut sqlx_query = sqlx::query(&string_query); 202 | 203 | // Bind the values in the order of the keys 204 | for key in keys.iter() { 205 | // Consume the value and convert it to a NativeType for proper binding 206 | let value = data.remove(key).unwrap(); 207 | let native_value = FinalType::try_from(value).unwrap(); 208 | sqlx_query = bind_mysql_value(sqlx_query, native_value); 209 | } 210 | 211 | // Bind the ID 212 | sqlx_query = bind_mysql_value(sqlx_query, id.clone()); 213 | 214 | let result = sqlx_query.fetch_optional(executor).await.unwrap(); 215 | 216 | if result.is_none() { 217 | return None; 218 | } 219 | 220 | let data = T::from_row(&result.unwrap()).unwrap(); 221 | 222 | // Produce the creation notification 223 | Some(OperationNotification::Update { 224 | table: table.to_string(), 225 | id: id.clone(), 226 | data, 227 | }) 228 | } 229 | GranularOperation::Delete { table, id } => { 230 | let string_query = delete_statement(&table); 231 | let mut sqlx_query = sqlx::query(&string_query); 232 | 233 | // Bind the ID 234 | sqlx_query = bind_mysql_value(sqlx_query, id.clone()); 235 | 236 | let result = sqlx_query.fetch_optional(executor).await.unwrap(); 237 | 238 | if result.is_none() { 239 | return None; 240 | } 241 | 242 | let data = T::from_row(&result.unwrap()).unwrap(); 243 | 244 | Some(OperationNotification::Delete { 245 | table: table.to_string(), 246 | id: id.clone(), 247 | data, 248 | }) 249 | } 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/database/sqlite.rs: -------------------------------------------------------------------------------- 1 | //! Particularized SQLite implementations. 2 | 3 | use sqlx::{ 4 | query::Query, 5 | sqlite::{SqliteArguments, SqliteRow}, 6 | Column, Executor, FromRow, Row, Sqlite, TypeInfo, 7 | }; 8 | 9 | use crate::{ 10 | operations::serialize::{GranularOperation, OperationNotification}, 11 | queries::serialize::{FinalType, QueryData, QueryTree, ReturnType}, 12 | utils::{ 13 | delete_statement, insert_many_statement, insert_statement, ordered_keys, 14 | to_numbered_placeholders, update_statement, 15 | }, 16 | }; 17 | 18 | use super::prepare_sqlx_query; 19 | 20 | /// Bind a native value to a Sqlite query 21 | #[inline] 22 | pub fn bind_sqlite_value<'q>( 23 | query: Query<'q, Sqlite, SqliteArguments<'q>>, 24 | value: FinalType, 25 | ) -> Query<'q, Sqlite, SqliteArguments<'q>> { 26 | match value { 27 | FinalType::Null => query.bind(None::), 28 | FinalType::Number(number) => { 29 | if number.is_f64() { 30 | query.bind(number.as_f64().unwrap()) 31 | } else { 32 | query.bind(number.as_i64().unwrap()) 33 | } 34 | } 35 | FinalType::String(string) => query.bind(string), 36 | FinalType::Bool(bool) => query.bind(bool), 37 | } 38 | } 39 | 40 | /// Fetch data using a serialized query tree from a SQLite database 41 | pub async fn fetch_sqlite_query<'a, E>(query: &QueryTree, executor: E) -> QueryData 42 | where 43 | E: Executor<'a, Database = Sqlite>, 44 | { 45 | // Prepare the query 46 | let (sql, values) = prepare_sqlx_query(&query); 47 | let with_placeholders = to_numbered_placeholders(&sql); 48 | let mut sqlx_query = sqlx::query(&with_placeholders); 49 | 50 | // Bind the values 51 | for value in values { 52 | sqlx_query = bind_sqlite_value(sqlx_query, value); 53 | } 54 | 55 | // Fetch one or many rows depending on the query 56 | match query.return_type { 57 | ReturnType::Single => { 58 | let row = sqlx_query.fetch_optional(executor).await.unwrap(); 59 | return QueryData::Single(row); 60 | } 61 | ReturnType::Many => { 62 | let rows = sqlx_query.fetch_all(executor).await.unwrap(); 63 | return QueryData::Many(rows); 64 | } 65 | } 66 | } 67 | 68 | /// Convert a SQLite row to a JSON object 69 | pub fn sqlite_row_to_json(row: &SqliteRow) -> serde_json::Value { 70 | let mut json_map = serde_json::Map::new(); 71 | 72 | for column in row.columns() { 73 | let column_name = column.name(); 74 | let column_type = column.type_info().name(); 75 | 76 | // Dynamically match the type and insert it into the JSON map 77 | let value = match column_type { 78 | "INTEGER" => row 79 | .try_get::(column_name) 80 | .ok() 81 | .map(serde_json::Value::from), 82 | "REAL" | "NUMERIC" => row 83 | .try_get::(column_name) 84 | .ok() 85 | .map(serde_json::Value::from), 86 | "BOOLEAN" => row 87 | .try_get::(column_name) 88 | .ok() 89 | .map(serde_json::Value::from), 90 | "TEXT" | "DATE" | "TIME" | "DATETIME" => row 91 | .try_get::(column_name) 92 | .ok() 93 | .map(serde_json::Value::from), 94 | "NULL" => Some(serde_json::Value::Null), 95 | "BLOB" => None, // Skip BLOB columns 96 | _ => None, // Handle other types as needed 97 | }; 98 | 99 | // Add to JSON map if value is present 100 | if let Some(v) = value { 101 | json_map.insert(column_name.to_string(), v); 102 | } else { 103 | json_map.insert(column_name.to_string(), serde_json::Value::Null); 104 | } 105 | } 106 | 107 | serde_json::Value::Object(json_map) 108 | } 109 | 110 | /// Convert a vector of SQLite rows to a JSON array 111 | pub fn sqlite_rows_to_json(rows: &[SqliteRow]) -> serde_json::Value { 112 | let mut json_array = Vec::new(); 113 | 114 | for row in rows { 115 | json_array.push(sqlite_row_to_json(row)); 116 | } 117 | 118 | serde_json::Value::Array(json_array) 119 | } 120 | 121 | /// Helper function signature for serializing SQLite rows to JSON 122 | /// by mapping them to different data structs implementing `FromRow` 123 | /// and `Serialize` depending on the table name. 124 | pub type SerializeRowsMapped = fn(&QueryData, table: &str) -> serde_json::Value; 125 | 126 | /// Perform a granular operation on a SQLite database. 127 | /// Returns a notification to be sent to clients. 128 | pub async fn granular_operation_sqlite<'a, E, T>( 129 | operation: GranularOperation, 130 | executor: E, 131 | ) -> Option> 132 | where 133 | E: Executor<'a, Database = Sqlite>, 134 | T: for<'r> FromRow<'r, SqliteRow>, 135 | { 136 | match operation { 137 | GranularOperation::Create { table, mut data } => { 138 | // Fix the order of the keys for later iterations 139 | let keys = ordered_keys(&data); 140 | 141 | // Produce the SQL query string 142 | let string_query = insert_statement(&table, &keys); 143 | let numbered_query = to_numbered_placeholders(&string_query); 144 | 145 | let mut sqlx_query = sqlx::query(&numbered_query); 146 | 147 | // Bind the values in the order of the keys 148 | for key in keys.iter() { 149 | // Consume the value and convert it to a NativeType for proper binding 150 | let value = data.remove(key).unwrap(); 151 | let native_value = FinalType::try_from(value).unwrap(); 152 | sqlx_query = bind_sqlite_value(sqlx_query, native_value); 153 | } 154 | 155 | let result = sqlx_query.fetch_one(executor).await.unwrap(); 156 | let data = T::from_row(&result).unwrap(); 157 | 158 | // Produce the creation notification 159 | Some(OperationNotification::Create { 160 | table: table.to_string(), 161 | data, 162 | }) 163 | } 164 | GranularOperation::CreateMany { table, mut data } => { 165 | // Fix the order of the keys for later iterations 166 | let keys = ordered_keys(&data[0]); 167 | 168 | // Produce the SQL query string 169 | let string_query = insert_many_statement(&table, &keys, data.len()); 170 | let numbered_query = to_numbered_placeholders(&string_query); 171 | 172 | let mut sqlx_query = sqlx::query(&numbered_query); 173 | 174 | // Bind all values in order of the keys 175 | for entry in data.iter_mut() { 176 | for key in keys.iter() { 177 | // Consume the value and convert it to a NativeType for proper binding 178 | let value = entry.remove(key).unwrap(); 179 | let native_value = FinalType::try_from(value).unwrap(); 180 | sqlx_query = bind_sqlite_value(sqlx_query, native_value); 181 | } 182 | } 183 | 184 | let results = sqlx_query.fetch_all(executor).await.unwrap(); 185 | let data: Vec = results 186 | .into_iter() 187 | .map(|row| T::from_row(&row).unwrap()) 188 | .collect(); 189 | 190 | // Produce the operation notification 191 | Some(OperationNotification::CreateMany { 192 | table: table.to_string(), 193 | data, 194 | }) 195 | } 196 | GranularOperation::Update { 197 | table, 198 | id, 199 | mut data, 200 | } => { 201 | // Fix the order of the keys for later iterations 202 | let keys = ordered_keys(&data); 203 | 204 | // Produce the SQL query string 205 | let string_query = update_statement(&table, &keys); 206 | let numbered_query = to_numbered_placeholders(&string_query); 207 | 208 | let mut sqlx_query = sqlx::query(&numbered_query); 209 | 210 | // Bind the values in the order of the keys 211 | for key in keys.iter() { 212 | // Consume the value and convert it to a NativeType for proper binding 213 | let value = data.remove(key).unwrap(); 214 | let native_value = FinalType::try_from(value).unwrap(); 215 | sqlx_query = bind_sqlite_value(sqlx_query, native_value); 216 | } 217 | 218 | // Bind the ID 219 | sqlx_query = bind_sqlite_value(sqlx_query, id.clone()); 220 | 221 | let result = sqlx_query.fetch_optional(executor).await.unwrap(); 222 | 223 | if result.is_none() { 224 | return None; 225 | } 226 | 227 | let data = T::from_row(&result.unwrap()).unwrap(); 228 | 229 | // Produce the creation notification 230 | Some(OperationNotification::Update { 231 | table: table.to_string(), 232 | id: id.clone(), 233 | data, 234 | }) 235 | } 236 | GranularOperation::Delete { table, id } => { 237 | let string_query = delete_statement(&table); 238 | let numbered_query = to_numbered_placeholders(&string_query); 239 | 240 | let mut sqlx_query = sqlx::query(&numbered_query); 241 | 242 | // Bind the ID 243 | sqlx_query = bind_sqlite_value(sqlx_query, id.clone()); 244 | 245 | let result = sqlx_query.fetch_optional(executor).await.unwrap(); 246 | 247 | if result.is_none() { 248 | return None; 249 | } 250 | 251 | let data = T::from_row(&result.unwrap()).unwrap(); 252 | 253 | Some(OperationNotification::Delete { 254 | table: table.to_string(), 255 | id: id.clone(), 256 | data, 257 | }) 258 | } 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /crates/real-time-sqlx/src/database/postgres.rs: -------------------------------------------------------------------------------- 1 | //! Particularized PostgreSQL implementations. 2 | 3 | use sqlx::{ 4 | postgres::{PgArguments, PgRow}, 5 | query::Query, 6 | Column, Executor, FromRow, Postgres, Row, TypeInfo, 7 | }; 8 | 9 | use crate::{ 10 | operations::serialize::{GranularOperation, OperationNotification}, 11 | queries::serialize::{FinalType, QueryData, QueryTree, ReturnType}, 12 | utils::{ 13 | delete_statement, insert_many_statement, insert_statement, ordered_keys, 14 | to_numbered_placeholders, update_statement, 15 | }, 16 | }; 17 | 18 | use super::prepare_sqlx_query; 19 | 20 | /// Bind a native value to a Postgres query 21 | #[inline] 22 | pub fn bind_postgres_value<'q>( 23 | query: Query<'q, Postgres, PgArguments>, 24 | value: FinalType, 25 | ) -> Query<'q, Postgres, PgArguments> { 26 | match value { 27 | FinalType::Null => query.bind(None::), 28 | FinalType::Number(number) => { 29 | if number.is_f64() { 30 | query.bind(number.as_f64().unwrap()) 31 | } else { 32 | query.bind(number.as_i64().unwrap()) 33 | } 34 | } 35 | FinalType::String(string) => query.bind(string), 36 | FinalType::Bool(bool) => query.bind(bool), 37 | } 38 | } 39 | 40 | /// Fetch data using a serialized query tree from a PostgreSQL database 41 | pub async fn fetch_postgres_query<'a, E>(query: &QueryTree, executor: E) -> QueryData 42 | where 43 | E: Executor<'a, Database = Postgres>, 44 | { 45 | // Prepare the query 46 | let (sql, values) = prepare_sqlx_query(&query); 47 | let with_placeholders = to_numbered_placeholders(&sql); 48 | let mut sqlx_query = sqlx::query(&with_placeholders); 49 | 50 | // Bind the values 51 | for value in values { 52 | sqlx_query = bind_postgres_value(sqlx_query, value); 53 | } 54 | 55 | // Fetch one or many rows depending on the query 56 | match query.return_type { 57 | ReturnType::Single => { 58 | let row = sqlx_query.fetch_optional(executor).await.unwrap(); 59 | return QueryData::Single(row); 60 | } 61 | ReturnType::Many => { 62 | let rows = sqlx_query.fetch_all(executor).await.unwrap(); 63 | return QueryData::Many(rows); 64 | } 65 | } 66 | } 67 | 68 | /// Convert a PostgreSQL row to a JSON object 69 | pub fn postgres_row_to_json(row: &PgRow) -> serde_json::Value { 70 | let mut json_map = serde_json::Map::new(); 71 | 72 | for column in row.columns() { 73 | let column_name = column.name(); 74 | let column_type = column.type_info().name(); 75 | 76 | // Dynamically match the type and insert it into the JSON map 77 | let value = match column_type { 78 | "INTEGER" => row 79 | .try_get::(column_name) 80 | .ok() 81 | .map(serde_json::Value::from), 82 | "REAL" | "NUMERIC" => row 83 | .try_get::(column_name) 84 | .ok() 85 | .map(serde_json::Value::from), 86 | "BOOLEAN" => row 87 | .try_get::(column_name) 88 | .ok() 89 | .map(serde_json::Value::from), 90 | "TEXT" | "DATE" | "TIME" | "DATETIME" => row 91 | .try_get::(column_name) 92 | .ok() 93 | .map(serde_json::Value::from), 94 | "NULL" => Some(serde_json::Value::Null), 95 | "BLOB" => None, // Skip BLOB columns 96 | _ => None, // Handle other types as needed 97 | }; 98 | 99 | // Add to JSON map if value is present 100 | if let Some(v) = value { 101 | json_map.insert(column_name.to_string(), v); 102 | } else { 103 | json_map.insert(column_name.to_string(), serde_json::Value::Null); 104 | } 105 | } 106 | 107 | serde_json::Value::Object(json_map) 108 | } 109 | 110 | /// Convert a vector of Postgres rows to a JSON array 111 | pub fn postgres_rows_to_json(rows: &[PgRow]) -> serde_json::Value { 112 | let mut json_array = Vec::new(); 113 | 114 | for row in rows { 115 | json_array.push(postgres_row_to_json(row)); 116 | } 117 | 118 | serde_json::Value::Array(json_array) 119 | } 120 | 121 | /// Helper function signature for serializing PostgreSQL rows to JSON 122 | /// by mapping them to different data structs implementing `FromRow` 123 | /// and `Serialize` depending on the table name. 124 | pub type SerializeRowsMapped = fn(&QueryData, table: &str) -> serde_json::Value; 125 | 126 | /// Perform a granular operation on a Postgres database. 127 | /// Returns a notification to be sent to clients. 128 | pub async fn granular_operation_postgres<'a, E, T>( 129 | operation: GranularOperation, 130 | executor: E, 131 | ) -> Option> 132 | where 133 | E: Executor<'a, Database = Postgres>, 134 | T: for<'r> FromRow<'r, PgRow>, 135 | { 136 | match operation { 137 | GranularOperation::Create { table, mut data } => { 138 | // Fix the order of the keys for later iterations 139 | let keys = ordered_keys(&data); 140 | 141 | // Produce the SQL query string 142 | let string_query = insert_statement(&table, &keys); 143 | let numbered_query = to_numbered_placeholders(&string_query); 144 | 145 | let mut sqlx_query = sqlx::query(&numbered_query); 146 | 147 | // Bind the values in the order of the keys 148 | for key in keys.iter() { 149 | // Consume the value and convert it to a NativeType for proper binding 150 | let value = data.remove(key).unwrap(); 151 | let native_value = FinalType::try_from(value).unwrap(); 152 | sqlx_query = bind_postgres_value(sqlx_query, native_value); 153 | } 154 | 155 | let result = sqlx_query.fetch_one(executor).await.unwrap(); 156 | let data = T::from_row(&result).unwrap(); 157 | 158 | // Produce the creation notification 159 | Some(OperationNotification::Create { 160 | table: table.to_string(), 161 | data, 162 | }) 163 | } 164 | GranularOperation::CreateMany { table, mut data } => { 165 | // Fix the order of the keys for later iterations 166 | let keys = ordered_keys(&data[0]); 167 | 168 | // Produce the SQL query string 169 | let string_query = insert_many_statement(&table, &keys, data.len()); 170 | let numbered_query = to_numbered_placeholders(&string_query); 171 | 172 | let mut sqlx_query = sqlx::query(&numbered_query); 173 | 174 | // Bind all values in order of the keys 175 | for entry in data.iter_mut() { 176 | for key in keys.iter() { 177 | // Consume the value and convert it to a NativeType for proper binding 178 | let value = entry.remove(key).unwrap(); 179 | let native_value = FinalType::try_from(value).unwrap(); 180 | sqlx_query = bind_postgres_value(sqlx_query, native_value); 181 | } 182 | } 183 | 184 | let results = sqlx_query.fetch_all(executor).await.unwrap(); 185 | let data: Vec = results 186 | .into_iter() 187 | .map(|row| T::from_row(&row).unwrap()) 188 | .collect(); 189 | 190 | // Produce the operation notification 191 | Some(OperationNotification::CreateMany { 192 | table: table.to_string(), 193 | data, 194 | }) 195 | } 196 | GranularOperation::Update { 197 | table, 198 | id, 199 | mut data, 200 | } => { 201 | // Fix the order of the keys for later iterations 202 | let keys = ordered_keys(&data); 203 | 204 | // Produce the SQL query string 205 | let string_query = update_statement(&table, &keys); 206 | let numbered_query = to_numbered_placeholders(&string_query); 207 | 208 | let mut sqlx_query = sqlx::query(&numbered_query); 209 | 210 | // Bind the values in the order of the keys 211 | for key in keys.iter() { 212 | // Consume the value and convert it to a NativeType for proper binding 213 | let value = data.remove(key).unwrap(); 214 | let native_value = FinalType::try_from(value).unwrap(); 215 | sqlx_query = bind_postgres_value(sqlx_query, native_value); 216 | } 217 | 218 | // Bind the ID 219 | sqlx_query = bind_postgres_value(sqlx_query, id.clone()); 220 | 221 | let result = sqlx_query.fetch_optional(executor).await.unwrap(); 222 | 223 | if result.is_none() { 224 | return None; 225 | } 226 | 227 | let data = T::from_row(&result.unwrap()).unwrap(); 228 | 229 | // Produce the creation notification 230 | Some(OperationNotification::Update { 231 | table: table.to_string(), 232 | id: id.clone(), 233 | data, 234 | }) 235 | } 236 | GranularOperation::Delete { table, id } => { 237 | let string_query = delete_statement(&table); 238 | let numbered_query = to_numbered_placeholders(&string_query); 239 | 240 | let mut sqlx_query = sqlx::query(&numbered_query); 241 | 242 | // Bind the ID 243 | sqlx_query = bind_postgres_value(sqlx_query, id.clone()); 244 | 245 | let result = sqlx_query.fetch_optional(executor).await.unwrap(); 246 | 247 | if result.is_none() { 248 | return None; 249 | } 250 | 251 | let data = T::from_row(&result.unwrap()).unwrap(); 252 | 253 | Some(OperationNotification::Delete { 254 | table: table.to_string(), 255 | id: id.clone(), 256 | data, 257 | }) 258 | } 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2024 Thibaut de Saivre 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Real-Time SQLx 2 | 3 |
4 | Tauri Version 5 | Rust 6 | SQLx 7 | TypeScript 8 |
9 | 10 |
11 | 12 |
13 | A simple Tauri real-time query engine inspired by Firestore 14 |
15 | 16 | ## Table of Contents 17 | 18 | 1. [About](#about) 19 | - [Inspirations](#inspirations) 20 | - [What this project is not](#what-this-project-is-not) 21 | 2. [Installation](#installation) 22 | 3. [Features](#features) 23 | - [Frontend](#frontend) 24 | - [Build SQL `SELECT` queries](#build-sql-select-queries) 25 | - [Subscribe to real-time changes](#subscribe-to-real-time-changes) 26 | - [Paginate SQL queries](#paginate-queries) 27 | - [Execute SQL operations](#execute-sql-operations) 28 | - [Execute raw SQL](#execute-raw-sql) 29 | - [Backend](#backend) 30 | - [Feature flags](#feature-flags) 31 | - [Configuration](#configuration) 32 | 4. [Roadmap](#roadmap) 33 | 5. [Behind the API](#behind-the-api) 34 | 35 | ## About 36 | 37 | This project is a real-time simplified SQL-subset query engine on top of a SQL database that enables you to **subscribe to database changes** in the frontend. It exposes simple query functions in the frontend that make it possible to query your database **directly from the frontend.** 38 | It is primarily thought for simple Tauri dashboard-like applications where you want display data that is always in sync with the local database, without having to handle cache invalidation, refetches, etc. 39 | 40 | This project relies on the [sqlx](https://github.com/launchbadge/sqlx) rust crate for database interaction, which you can use for more complex SQL operations that are not supported by this project. 41 | 42 | ### Inspirations 43 | 44 | - [Kysely](https://kysely.dev/): for the typescript query builder frontend. 45 | - [Firestore](https://firebase.google.com/docs/firestore): for the real-time subscription system, and the idea of executing queries directly from the frontend instead of having to implement a backend endpoint for every query. 46 | 47 | ### What this project is not 48 | 49 | - **Not scalable**: query subscriptions are shared on a per-table basis, which is not suitable for multi-consumer applications where you would want to restrict shared updates even more in order to avoid flooding the system with unrelated update signals. 50 | - **Not secure**: there is no security rules to restrict the access to some tables like in firebase. If you wire a table to this system, anyone having access to the frontend code can access anything in it. This is suited for desktop use with local, per-user sqlite databases. 51 | - **Not continuous**: there is currently no way for frontend channels to resubscribe to the backend when their connection breaks, making the project not suitable for situations where you would want to continuously update and restart backend instances with almost seamless subscription transitions in the frontend. 52 | 53 | ## Installation 54 | 55 | For NixOS users, a devshell is provided for this project (which works with Tauri). Run `nix develop`. 56 | 57 | Add the backend crate in your `Cargo.toml`: 58 | 59 | ```toml 60 | [dependencies] 61 | real-time-sqlx = { version = "0.1", features = ["sqlite", "tauri"] } 62 | ``` 63 | 64 | Install the frontend with your favorite package manager: 65 | 66 | ```bash 67 | bun add real-time-sqlx 68 | ``` 69 | 70 | ## Features 71 | 72 | This project assumes that the models you intend to wire to it all possess an `id` column that serves as their primary key. It can be of any type. 73 | 74 | Features summary: 75 | 76 | - **Type-safe query builder** inspired by [Kysely](https://kysely.dev/) 77 | - **Real-time database subscriptions** inspired by [Firestore](https://firebase.google.com/docs/firestore) 78 | - Execute SQL queries directly from the frontend with _very little boilerplate_. 79 | 80 | ### Frontend 81 | 82 | Define your typescript models: 83 | 84 | ```typescript 85 | import { Indexable } from "real-time-sqlx"; 86 | 87 | interface Model extends Indexable { 88 | id: number; 89 | title: string; 90 | content: string; 91 | } 92 | 93 | interface Todo extends Indexable { 94 | id: string; // Strings are valid IDs 95 | title: string; 96 | content: string; 97 | } 98 | ``` 99 | 100 | Then define a database interface that contains all of your models: 101 | 102 | ```typescript 103 | interface Database { 104 | models: Model; // Name the attributes with the same name as the corresponding table! 105 | todos: Todos; 106 | } 107 | ``` 108 | 109 | Finally, you can instanciate a `SQLx instance`. It will ensure that your queries are valid in a type-safe way: 110 | 111 | ```typescript 112 | import { SQLx } from "real-time-sqlx"; 113 | 114 | // Export it through your app 115 | export const sqlx = new SQLx("sqlite"); 116 | ``` 117 | 118 | The following methods are made available by this instance: 119 | 120 | - `select`: build SQL `SELECT` queries, fetched using the following functions: 121 | - `fetchOne`/`fetchMany`: fetch a SQL query once 122 | - `subscribeOne`/`subscribeMany`: fetch a SQL query and subscribe to its changes 123 | - `create`: create a row 124 | - `createMany`: create many rows at once 125 | - `update`: update a row 126 | - `remove`: delete a row 127 | - `rawOne`: execute a sql query, returning one row at most 128 | - `rawMany`: execute a sql query, returning all rows 129 | 130 | #### Build SQL `SELECT` queries 131 | 132 | Select one or multiple rows from a table: 133 | 134 | ```typescript 135 | // The return types explicited here are inferred automatically! 136 | const one: SingleQueryData = await sqlx.select("model").fetchOne(); 137 | const many: ManyQueryData = await sqlx.select("model").fetchMany(); 138 | ``` 139 | 140 | Add and chain conditions: 141 | 142 | ```typescript 143 | const { data } = await sqlx 144 | .select("model") 145 | .where("id", ">", 4) 146 | .and("title", "ilike", "%hello%") 147 | .fetchOne(); 148 | ``` 149 | 150 | Nest conditions: 151 | 152 | ```typescript 153 | const { data } = await sqlx 154 | .select("models") 155 | .where("id", ">", 4) 156 | .andCallback((builder) => 157 | builder.where("title", "ilike", "%hello%").or("title", "ilike", "%hello%"), 158 | ) 159 | .fetchMany(); 160 | ``` 161 | 162 | Supported SQL operators: 163 | 164 | - `=` 165 | - `!=` 166 | - `<` 167 | - `>` 168 | - `<=` 169 | - `>=` 170 | - `like` 171 | - `ilike` 172 | - `in` 173 | 174 | Unsupported SQL conditions: 175 | 176 | - `JOIN` (too complex to implement, not suitable for real-time subscriptions) 177 | - `ORDER BY`, `LIMIT`, `OFFSET` (planned for a future release) 178 | 179 | #### Subscribe to real-time changes 180 | 181 | Fetch some data and subscribe to real-time changes: 182 | 183 | ```typescript 184 | const unsubscribe = sqlx 185 | .select("models") 186 | .subscribeMany( 187 | (data: Model[], updates: OperationNotification | null) => 188 | console.log(JSON.stringify(data)), 189 | ); 190 | ``` 191 | 192 | The `unsubscribe` function returned allows you to terminate the subscription early. It is recommended to call it at destruction, although the backend automatically prunes errored / terminated subscriptions. 193 | 194 | #### Paginate queries 195 | 196 | Pagination options are supported for SQL queries. By default, if pagination options are specified without the `orderBy` clause, the results will be ordered by `id DESC` (most recent entries first, for autoincrement primary keys). 197 | 198 | ```typescript 199 | const { data } = await sqlx.select("model").fetchOne({ 200 | perPage: 10, 201 | offset: 0, 202 | orderBy: { column: "id", order: "desc" }, 203 | }); 204 | ``` 205 | 206 | ```typescript 207 | const { data } = await sqlx.select("model").fetchMany({ 208 | perPage: 10, 209 | offset: 0, 210 | orderBy: { column: "id", order: "desc" }, 211 | }); 212 | ``` 213 | 214 | You can subscribe to real-time paginated queries! The `paginate` method returns an unsubscribe function as well as a `fetchMore` function to iterate over the data. 215 | 216 | ```typescript 217 | const [unsubscribe, fetchMore] = sqlx.select("model").paginate( 218 | { 219 | perPage: 10, 220 | offset: 0, 221 | orderBy: { column: "id", order: "desc" }, 222 | }, 223 | (data: Model[], updates: OperationNotification | null) => 224 | console.log(JSON.stringify(data)), 225 | ); 226 | 227 | const affectedRowCount = await fetchMore(); 228 | ``` 229 | 230 | #### Execute SQL operations 231 | 232 | The return types are explicited here for clarity purposes, but they are actually dynamically inferred from the `Model` type and the operation being performed. 233 | 234 | Insert a row: 235 | 236 | ```typescript 237 | const notification: OperationNotificationCreate | null = 238 | await sqlx.create("models", { title: "title", content: "content" }); 239 | ``` 240 | 241 | Insert many rows at once: 242 | 243 | ```typescript 244 | const notification: OperationNotificationCreateMany | null = 245 | await sqlx.createMany("models", [ 246 | { title: "title 1", content: "content 1" }, 247 | { title: "title 2", content: "content 2" }, 248 | ]); 249 | ``` 250 | 251 | Update a row: 252 | 253 | ```typescript 254 | const notification: OperationNotificationUpdate | null = 255 | await sqlx.update( 256 | "models", 257 | 3, // ID 258 | { title: "new title", content: "new content" }, 259 | ); 260 | ``` 261 | 262 | Delete a row: 263 | 264 | ```typescript 265 | const notification: OperationNotificationUpdate | null = 266 | await sqlx.delete( 267 | "models", 268 | 42, // ID 269 | ); 270 | ``` 271 | 272 | #### Execute raw SQL 273 | 274 | Because these simple query builders do not include all possible SQL operations, 2 more methods exist to execute raw prepared SQL queries. 275 | 276 | Execute a SQL query and return at most one row (or `null` if nothing is returned): 277 | 278 | ```typescript 279 | const data: Model | null = await sqlx.rawOne( 280 | "SELECT * from models where id = ?", 281 | [42], 282 | ); 283 | ``` 284 | 285 | Execute a SQL query and return all found rows: 286 | 287 | ```typescript 288 | const data: Model[] = await sqlx.rawMany("SELECT * from models where id > ?", [ 289 | 1, 290 | ]); 291 | ``` 292 | 293 | ### Backend 294 | 295 | #### Feature Flags 296 | 297 | The rust backend exposes the following feature flags. It is intended for use with Tauri. 298 | 299 | - `postgres`: PostgreSQL database compatibility 300 | - `mysql`: MySQL database compatibility 301 | - `sqlite`: Sqlite database compatibility 302 | - `tauri`: Complete Tauri integration 303 | 304 | #### Configuration 305 | 306 | Create your rust database models (note that the models do not need to implement `Deserialize`): 307 | 308 | ```rust 309 | #[derive(sqlx::FromRow, serde::Serialize, Clone)] 310 | pub struct Model { 311 | pub id: i64, 312 | pub title: String, 313 | pub content: String, 314 | } 315 | 316 | 317 | #[derive(sqlx::FromRow, serde::Serialize, Clone)] 318 | pub struct Todo { 319 | pub id: String, 320 | pub title: String, 321 | pub content: String, 322 | } 323 | ``` 324 | 325 | Generate the boilerplate code that creates the structures and Tauri commands needed to communicate with the frontend. 326 | You have to specify `(sql table name, rust struct)` pairs that will be matched together by the engine. 327 | 328 | ```rust 329 | real_time_sqlx::real_time_tauri!(sqlite, ("models", Model), ("todos", Todo)); // For Sqlite (recommended for Tauri) 330 | ``` 331 | 332 | Although `Sqlite`, `MySQL` and `PostgreSQL` are all supported (courtesy of `sqlx`), only `Sqlite` is recommended for use. 333 | 334 | This macro generates a `RealTimeDispatcher` struct that will handle the `Channel` connections to the frontend, perform SQL operations, 335 | and notify the relevant channels. 336 | 337 | It also creates the following Tauri commands: 338 | 339 | - `fetch` 340 | - `execute` 341 | - `subscribe` 342 | - `unsubscribe` 343 | - `raw` 344 | 345 | These Tauri commands expect 2 states to be managed by Tauri: 346 | 347 | - A database pool (`sqlx::Pool` for instance) 348 | - A real-time dispatcher 349 | 350 | Your `lib.rs` should look like this: 351 | 352 | ```rust 353 | 354 | async fn create_database_pool() -> Result, sqlx::Error> { 355 | let options = SqliteConnectOptions::new(); 356 | 357 | // Need to do this because else `log_statements` is not detected 358 | let options = options 359 | .filename("/path/to/your/database.db") 360 | .disable_statement_logging() 361 | .create_if_missing(true); 362 | 363 | SqlitePoolOptions::new() 364 | .max_connections(5) 365 | .connect_with(options) 366 | .await 367 | } 368 | 369 | 370 | #[cfg_attr(mobile, tauri::mobile_entry_point)] 371 | pub fn run() { 372 | // Create database pool 373 | let pool = 374 | async_runtime::block_on(create_database_pool()).expect("Failed to create database pool"); 375 | 376 | // Run your migrations with sqlx::migrate! 377 | 378 | tauri::Builder::default() 379 | .plugin(tauri_plugin_shell::init()) 380 | .manage(pool) 381 | .manage(RealTimeDispatcher::new()) 382 | .invoke_handler(tauri::generate_handler![ 383 | // Include the generated Tauri commands 384 | fetch, 385 | subscribe, 386 | unsubscribe, 387 | execute, 388 | raw 389 | ]) 390 | .run(tauri::generate_context!()) 391 | .expect("error while running tauri application"); 392 | } 393 | ``` 394 | 395 | > [!WARNING] 396 | > Do not call the `real_time_tauri!` macro in your Tauri `lib.rs` file! It will cause issues. 397 | 398 | ## Roadmap 399 | 400 | - [x] Add support for pagination (`ORDER BY`, `LIMIT`, `OFFSET`) 401 | - [x] Add model-related type-safety for the frontend builders 402 | - [x] Expose a raw SQL endpoint for SQL queries not supported by the real-time system, but that you still might want to execute with the same ease. 403 | - [ ] Add end-to-end testing. 404 | - [ ] Add support for other `id` names (using an optional additional argument) 405 | 406 | ## Behind the API 407 | 408 | See the [Backend README](./crates/real-time-sqlx/README.md). 409 | -------------------------------------------------------------------------------- /docs/real-time-engine.excalidraw.md: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | excalidraw-plugin: parsed 4 | tags: [excalidraw] 5 | 6 | --- 7 | ==⚠ Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠== You can decompress Drawing data with the command palette: 'Decompress current Excalidraw file'. For more info check in plugin settings under 'Saving' 8 | 9 | 10 | # Excalidraw Data 11 | ## Text Elements 12 | - table name 13 | - return type (single /many) 14 | - condition ^JnEPIhYj 15 | 16 | - AND (2 child conditions) 17 | - OR (2 child conditions) 18 | - Single (1 constraint) ^SqcZvTIb 19 | 20 | QueryTree ^Pt5Xh7Im 21 | 22 | Condition ^7KEEhlYT 23 | 24 | - column name 25 | - operator (=, <, >, ...) 26 | - constraint value ^sVaDk0kH 27 | 28 | Constraint ^KDJ3PA7H 29 | 30 | - final value (prevent recursive lists) 31 | - list of final values ^2qG56wJf 32 | 33 | Constraint Value ^ojuSxpTV 34 | 35 | - json number 36 | - string 37 | - bool 38 | - NULL ^bu6UKgIq 39 | 40 | Final Value ^JVsr71p4 41 | 42 | Query Syntax Tree ^sN25zQFY 43 | 44 | Database Operation ^ZsiiwMO3 45 | 46 | - Create 47 | - Create Many 48 | - Update 49 | - Delete ^rEg4fknq 50 | 51 | Granular Operation ^xQZjwvnP 52 | 53 | - Single Optional Row 54 | - Many Rows ^1CaT1zcX 55 | 56 | QueryData (once) ^rus26jvd 57 | 58 | - Create 59 | - Create Many 60 | - Update 61 | -Delete ^mfuWzGaG 62 | 63 | Operation Notification ^TEAO3Taz 64 | 65 | RealTimeScheduler (lazy) ^oM7KUmiG 66 | 67 | - For each table: RwLock> ^xN3iUNyj 68 | 69 | Tauri Channel ^n6RjvnoR 70 | 71 | - receives operation notifications ^Frkl2IuC 72 | 73 | + ^mHLKtoBf 74 | 75 | Subscription Block ^rMUhg0Sg 76 | 77 | Real-time updates sent to the frontend ^k7EcPPuP 78 | 79 | Real-Time Subscription Engine ^0BNIkCLU 80 | 81 | ## Embedded Files 82 | 5be8ded4485ed92f9df4956bbc359c417a903992: [[database-39.svg]] 83 | 84 | %% 85 | ## Drawing 86 | ```compressed-json 87 | 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 274 | ``` 275 | %% --------------------------------------------------------------------------------