diff --git a/Cargo.lock b/Cargo.lock index 7e23b4e4..fefed768 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -499,6 +499,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -542,6 +554,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "font-types" version = "0.9.0" @@ -835,6 +853,18 @@ name = "hashbrown" version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown", +] [[package]] name = "heck" @@ -992,10 +1022,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -1062,6 +1094,16 @@ dependencies = [ "redox_syscall", ] +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "pkg-config", + "vcpkg", +] + [[package]] name = "linesweeper" version = "0.3.0" @@ -1310,6 +1352,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "plist" version = "1.7.2" @@ -1505,6 +1553,20 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -1685,6 +1747,15 @@ dependencies = [ "serde", ] +[[package]] +name = "shift-store" +version = "0.1.0" +dependencies = [ + "rusqlite", + "tempfile", + "thiserror 2.0.18", +] + [[package]] name = "shift-wire" version = "0.0.0" @@ -1942,6 +2013,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "vergen" version = "9.0.6" @@ -1994,35 +2071,22 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2030,22 +2094,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] diff --git a/Cargo.toml b/Cargo.toml index eee6f173..d7d13ec4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,3 +9,4 @@ shift-backends = { path = "crates/shift-backends" } shift-bridge = { path = "crates/shift-bridge" } shift-edit = { path = "crates/shift-edit" } shift-ir = { path = "crates/shift-ir" } +shift-store = { path = "crates/shift-store" } diff --git a/crates/shift-store/Cargo.toml b/crates/shift-store/Cargo.toml new file mode 100644 index 00000000..3182e61a --- /dev/null +++ b/crates/shift-store/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "shift-store" +version = "0.1.0" +edition = "2024" + +[dependencies] +rusqlite = { version = "0.37.0", features = ["blob", "backup", "limits"] } +thiserror = "2" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/shift-store/README.md b/crates/shift-store/README.md new file mode 100644 index 00000000..504bcea9 --- /dev/null +++ b/crates/shift-store/README.md @@ -0,0 +1,40 @@ +# shift-store + +`shift-store` owns Shift's durable source database. + +The crate provides the storage boundary between the rest of the Rust application and SQLite. Callers should use typed store APIs exposed by this crate instead of preparing SQL statements or opening database connections directly. + +## Responsibilities + +- open and configure the SQLite connection; +- create and update the database schema; +- expose typed Rust APIs for source-store reads and writes; +- keep raw SQL local to this crate; +- preserve stable internal IDs for source entities; +- separate canonical source data from derived/index data as the store grows. + +## Design Goals + +- incremental writes; +- durability and crash-safe recovery; +- stable IDs; +- performant reads and writes for large fonts; +- clear handling of CJK-scale glyph inventories; +- efficient queries for references to and from source entities; +- support for components, kerning, and feature-related source data; +- clear separation between canonical source data and derived/index data; +- import/export support through typed store APIs rather than ad hoc SQL access. + +## Shape + +```text +src/ + connection.rs # opening and configuring SQLite + error.rs # store error type + glyph.rs # glyph persistence API + schema.rs # schema creation and versioning entry point + store.rs # ShiftStore connection owner + types.rs # storage-facing IDs and small value types +``` + +Tests live in `tests/store_test.rs` and should start as small persistence checks before broader integration tests are added. diff --git a/crates/shift-store/src/component.rs b/crates/shift-store/src/component.rs new file mode 100644 index 00000000..9c042711 --- /dev/null +++ b/crates/shift-store/src/component.rs @@ -0,0 +1,97 @@ +use crate::{ComponentId, GlyphId, LayerId, ShiftStore, StoreError}; + +pub struct NewGlyphComponent { + pub id: ComponentId, + pub layer_id: LayerId, + pub base_glyph_id: GlyphId, + pub order_index: i64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GlyphComponentRecord { + pub id: ComponentId, + pub layer_id: LayerId, + pub base_glyph_id: GlyphId, + pub order_index: i64, +} + +impl ShiftStore { + pub fn create_glyph_component( + &mut self, + component: NewGlyphComponent, + ) -> Result<(), StoreError> { + self.conn.execute( + " + INSERT INTO glyph_components ( + id, + layer_id, + base_glyph_id, + order_index + ) + VALUES (?1, ?2, ?3, ?4) + ", + rusqlite::params![ + component.id.as_str(), + component.layer_id.as_str(), + component.base_glyph_id.as_str(), + component.order_index, + ], + )?; + + Ok(()) + } + + pub fn get_glyph_component( + &self, + id: &ComponentId, + ) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + " + SELECT + id, + layer_id, + base_glyph_id, + order_index + FROM glyph_components + WHERE id = ?1 + ", + )?; + + match stmt.query_row([id.as_str()], map_glyph_component_row) { + Ok(component) => Ok(Some(component)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(err) => Err(err.into()), + } + } + + pub fn list_glyph_components_for_layer( + &self, + layer_id: &LayerId, + ) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + " + SELECT + id, + layer_id, + base_glyph_id, + order_index + FROM glyph_components + WHERE layer_id = ?1 + ORDER BY order_index, id + ", + )?; + + let rows = stmt.query_map([layer_id.as_str()], map_glyph_component_row)?; + rows.collect::, _>>() + .map_err(StoreError::from) + } +} + +fn map_glyph_component_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(GlyphComponentRecord { + id: ComponentId::new(row.get::<_, String>(0)?), + layer_id: LayerId::new(row.get::<_, String>(1)?), + base_glyph_id: GlyphId::new(row.get::<_, String>(2)?), + order_index: row.get(3)?, + }) +} diff --git a/crates/shift-store/src/connection.rs b/crates/shift-store/src/connection.rs new file mode 100644 index 00000000..d859a5e6 --- /dev/null +++ b/crates/shift-store/src/connection.rs @@ -0,0 +1,25 @@ +use std::path::Path; + +use crate::{ShiftStore, StoreError, schema}; + +impl ShiftStore { + pub fn open(path: impl AsRef) -> Result { + let conn = rusqlite::Connection::open(path)?; + configure_connection(&conn)?; + schema::ensure_current(&conn)?; + Ok(Self { conn }) + } + + pub fn open_memory_for_test() -> Result { + let conn = rusqlite::Connection::open_in_memory()?; + configure_connection(&conn)?; + schema::ensure_current(&conn)?; + Ok(Self { conn }) + } +} + +fn configure_connection(conn: &rusqlite::Connection) -> Result<(), StoreError> { + conn.pragma_update(None, "foreign_keys", "ON")?; + conn.busy_timeout(std::time::Duration::from_millis(5_000))?; + Ok(()) +} diff --git a/crates/shift-store/src/error.rs b/crates/shift-store/src/error.rs new file mode 100644 index 00000000..2de79f1d --- /dev/null +++ b/crates/shift-store/src/error.rs @@ -0,0 +1,8 @@ +#[derive(Debug, thiserror::Error)] +pub enum StoreError { + #[error("sqlite error")] + Sqlite(#[from] rusqlite::Error), + + #[error("unknown source kind: {0}")] + UnknownSourceKind(String), +} diff --git a/crates/shift-store/src/glyph.rs b/crates/shift-store/src/glyph.rs new file mode 100644 index 00000000..8862564a --- /dev/null +++ b/crates/shift-store/src/glyph.rs @@ -0,0 +1,49 @@ +use crate::{GlyphId, ShiftStore, StoreError}; + +pub struct NewGlyph { + pub id: GlyphId, + pub name: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GlyphRecord { + pub id: GlyphId, + pub name: Option, +} + +impl ShiftStore { + pub fn create_glyph(&mut self, glyph: NewGlyph) -> Result<(), StoreError> { + self.conn.execute( + " + INSERT INTO glyphs (id, name) + VALUES (?1, ?2) + ", + rusqlite::params![glyph.id.as_str(), glyph.name], + )?; + + Ok(()) + } + + pub fn get_glyph(&self, id: &GlyphId) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + " + SELECT id, name + FROM glyphs + WHERE id = ?1 + ", + )?; + + match stmt.query_row([id.as_str()], map_glyph_row) { + Ok(glyph) => Ok(Some(glyph)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(err) => Err(err.into()), + } + } +} + +fn map_glyph_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(GlyphRecord { + id: GlyphId::new(row.get::<_, String>(0)?), + name: row.get(1)?, + }) +} diff --git a/crates/shift-store/src/layer.rs b/crates/shift-store/src/layer.rs new file mode 100644 index 00000000..626fcd9f --- /dev/null +++ b/crates/shift-store/src/layer.rs @@ -0,0 +1,91 @@ +use crate::{GlyphId, LayerId, ShiftStore, SourceId, StoreError}; + +pub struct NewGlyphLayer { + pub id: LayerId, + pub glyph_id: GlyphId, + pub source_id: SourceId, + pub name: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GlyphLayerRecord { + pub id: LayerId, + pub glyph_id: GlyphId, + pub source_id: SourceId, + pub name: Option, +} + +impl ShiftStore { + pub fn create_glyph_layer(&mut self, layer: NewGlyphLayer) -> Result<(), StoreError> { + self.conn.execute( + " + INSERT INTO glyph_layers ( + id, + glyph_id, + source_id, + name + ) + VALUES (?1, ?2, ?3, ?4) + ", + rusqlite::params![ + layer.id.as_str(), + layer.glyph_id.as_str(), + layer.source_id.as_str(), + layer.name, + ], + )?; + + Ok(()) + } + + pub fn get_glyph_layer(&self, id: &LayerId) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + " + SELECT + id, + glyph_id, + source_id, + name + FROM glyph_layers + WHERE id = ?1 + ", + )?; + + match stmt.query_row([id.as_str()], map_glyph_layer_row) { + Ok(layer) => Ok(Some(layer)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(err) => Err(err.into()), + } + } + + pub fn list_glyph_layers_for_glyph( + &self, + glyph_id: &GlyphId, + ) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + " + SELECT + id, + glyph_id, + source_id, + name + FROM glyph_layers + WHERE glyph_id = ?1 + ORDER BY source_id, id + ", + )?; + + let rows = stmt.query_map([glyph_id.as_str()], map_glyph_layer_row)?; + rows.collect::, _>>() + .map_err(StoreError::from) + } +} + +fn map_glyph_layer_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(GlyphLayerRecord { + id: LayerId::new(row.get::<_, String>(0)?), + glyph_id: GlyphId::new(row.get::<_, String>(1)?), + source_id: SourceId::new(row.get::<_, String>(2)?), + name: row.get(3)?, + }) +} diff --git a/crates/shift-store/src/lib.rs b/crates/shift-store/src/lib.rs new file mode 100644 index 00000000..4fbeaae2 --- /dev/null +++ b/crates/shift-store/src/lib.rs @@ -0,0 +1,17 @@ +mod component; +mod connection; +mod error; +mod glyph; +mod layer; +mod schema; +mod source; +mod store; +mod types; + +pub use component::{GlyphComponentRecord, NewGlyphComponent}; +pub use error::StoreError; +pub use glyph::{GlyphRecord, NewGlyph}; +pub use layer::{GlyphLayerRecord, NewGlyphLayer}; +pub use source::{AxisRecord, NewAxis, NewSource, SourceAxisLocation, SourceKind, SourceRecord}; +pub use store::ShiftStore; +pub use types::{AxisId, ComponentId, GlyphId, LayerId, RevisionId, SourceId}; diff --git a/crates/shift-store/src/schema.rs b/crates/shift-store/src/schema.rs new file mode 100644 index 00000000..84ec6100 --- /dev/null +++ b/crates/shift-store/src/schema.rs @@ -0,0 +1,82 @@ +use crate::StoreError; + +pub(crate) const SCHEMA_V1: &str = r#" +CREATE TABLE IF NOT EXISTS axes ( + id TEXT PRIMARY KEY, + tag TEXT NOT NULL, + name TEXT NOT NULL, + min_value REAL NOT NULL, + default_value REAL NOT NULL, + max_value REAL NOT NULL, + hidden INTEGER NOT NULL DEFAULT 0 CHECK (hidden IN (0, 1)) +); + +CREATE UNIQUE INDEX IF NOT EXISTS axes_tag_unique +ON axes(tag); + +CREATE TABLE IF NOT EXISTS sources ( + id TEXT PRIMARY KEY, + name TEXT, + family_name TEXT, + style_name TEXT, + kind TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS glyphs ( + id TEXT PRIMARY KEY, + name TEXT +); + +CREATE INDEX IF NOT EXISTS glyphs_name_idx +ON glyphs(name); + +CREATE TABLE IF NOT EXISTS glyph_layers ( + id TEXT PRIMARY KEY, + glyph_id TEXT NOT NULL, + source_id TEXT NOT NULL, + name TEXT, + FOREIGN KEY (glyph_id) REFERENCES glyphs(id) ON DELETE CASCADE, + FOREIGN KEY (source_id) REFERENCES sources(id) ON DELETE CASCADE +); + +CREATE UNIQUE INDEX IF NOT EXISTS glyph_layers_glyph_source_unique +ON glyph_layers(glyph_id, source_id); + +CREATE INDEX IF NOT EXISTS glyph_layers_glyph_id_idx +ON glyph_layers(glyph_id); + +CREATE INDEX IF NOT EXISTS glyph_layers_source_id_idx +ON glyph_layers(source_id); + +CREATE TABLE IF NOT EXISTS glyph_components ( + id TEXT PRIMARY KEY, + layer_id TEXT NOT NULL, + base_glyph_id TEXT NOT NULL, + order_index INTEGER NOT NULL, + FOREIGN KEY (layer_id) REFERENCES glyph_layers(id) ON DELETE CASCADE, + FOREIGN KEY (base_glyph_id) REFERENCES glyphs(id) ON DELETE CASCADE +); + +CREATE UNIQUE INDEX IF NOT EXISTS glyph_components_layer_order_unique +ON glyph_components(layer_id, order_index); + +CREATE INDEX IF NOT EXISTS glyph_components_layer_id_idx +ON glyph_components(layer_id); + +CREATE INDEX IF NOT EXISTS glyph_components_base_glyph_id_idx +ON glyph_components(base_glyph_id); + +CREATE TABLE IF NOT EXISTS source_locations ( + source_id TEXT NOT NULL, + axis_id TEXT NOT NULL, + value REAL NOT NULL, + PRIMARY KEY (source_id, axis_id), + FOREIGN KEY (source_id) REFERENCES sources(id) ON DELETE CASCADE, + FOREIGN KEY (axis_id) REFERENCES axes(id) ON DELETE CASCADE +); +"#; + +pub(crate) fn ensure_current(conn: &rusqlite::Connection) -> Result<(), StoreError> { + conn.execute_batch(SCHEMA_V1)?; + Ok(()) +} diff --git a/crates/shift-store/src/source.rs b/crates/shift-store/src/source.rs new file mode 100644 index 00000000..dbe35953 --- /dev/null +++ b/crates/shift-store/src/source.rs @@ -0,0 +1,254 @@ +use crate::{AxisId, ShiftStore, SourceId, StoreError}; + +pub struct NewAxis { + pub id: AxisId, + pub tag: String, + pub name: String, + pub min_value: f64, + pub default_value: f64, + pub max_value: f64, + pub hidden: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct AxisRecord { + pub id: AxisId, + pub tag: String, + pub name: String, + pub min_value: f64, + pub default_value: f64, + pub max_value: f64, + pub hidden: bool, +} + +pub struct NewSource { + pub id: SourceId, + pub name: Option, + pub family_name: Option, + pub style_name: Option, + pub kind: SourceKind, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SourceRecord { + pub id: SourceId, + pub name: Option, + pub family_name: Option, + pub style_name: Option, + pub kind: SourceKind, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SourceKind { + Master, +} + +impl SourceKind { + fn as_str(&self) -> &'static str { + match self { + SourceKind::Master => "master", + } + } + + fn parse(value: String) -> Result { + match value.as_str() { + "master" => Ok(SourceKind::Master), + _ => Err(StoreError::UnknownSourceKind(value)), + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SourceAxisLocation { + pub source_id: SourceId, + pub axis_id: AxisId, + pub value: f64, +} + +impl ShiftStore { + pub fn create_axis(&mut self, axis: NewAxis) -> Result<(), StoreError> { + self.conn.execute( + " + INSERT INTO axes ( + id, + tag, + name, + min_value, + default_value, + max_value, + hidden + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ", + rusqlite::params![ + axis.id.as_str(), + axis.tag, + axis.name, + axis.min_value, + axis.default_value, + axis.max_value, + axis.hidden, + ], + )?; + + Ok(()) + } + + pub fn get_axis(&self, id: &AxisId) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + " + SELECT + id, + tag, + name, + min_value, + default_value, + max_value, + hidden + FROM axes + WHERE id = ?1 + ", + )?; + + match stmt.query_row([id.as_str()], map_axis_row) { + Ok(axis) => Ok(Some(axis)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(err) => Err(err.into()), + } + } + + pub fn create_source(&mut self, source: NewSource) -> Result<(), StoreError> { + self.conn.execute( + " + INSERT INTO sources ( + id, + name, + family_name, + style_name, + kind + ) + VALUES (?1, ?2, ?3, ?4, ?5) + ", + rusqlite::params![ + source.id.as_str(), + source.name, + source.family_name, + source.style_name, + source.kind.as_str(), + ], + )?; + + Ok(()) + } + + pub fn get_source(&self, id: &SourceId) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + " + SELECT + id, + name, + family_name, + style_name, + kind + FROM sources + WHERE id = ?1 + ", + )?; + + match stmt.query_row([id.as_str()], map_source_row) { + Ok(source) => Ok(Some(source)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(err) => Err(err.into()), + } + } + + pub fn list_sources(&self) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + " + SELECT + id, + name, + family_name, + style_name, + kind + FROM sources + ORDER BY id + ", + )?; + + let rows = stmt.query_map([], map_source_row)?; + rows.collect::, _>>() + .map_err(StoreError::from) + } + + pub fn set_source_location( + &mut self, + source_id: &SourceId, + axis_id: &AxisId, + value: f64, + ) -> Result<(), StoreError> { + self.conn.execute( + " + INSERT INTO source_locations (source_id, axis_id, value) + VALUES (?1, ?2, ?3) + ON CONFLICT(source_id, axis_id) DO UPDATE SET + value = excluded.value + ", + rusqlite::params![source_id.as_str(), axis_id.as_str(), value], + )?; + + Ok(()) + } + + pub fn get_source_locations( + &self, + source_id: &SourceId, + ) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + " + SELECT source_id, axis_id, value + FROM source_locations + WHERE source_id = ?1 + ORDER BY axis_id + ", + )?; + + let rows = stmt.query_map([source_id.as_str()], map_source_location_row)?; + rows.collect::, _>>() + .map_err(StoreError::from) + } +} + +fn map_axis_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(AxisRecord { + id: AxisId::new(row.get::<_, String>(0)?), + tag: row.get(1)?, + name: row.get(2)?, + min_value: row.get(3)?, + default_value: row.get(4)?, + max_value: row.get(5)?, + hidden: row.get(6)?, + }) +} + +fn map_source_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let kind = SourceKind::parse(row.get(4)?).map_err(|err| { + rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Text, Box::new(err)) + })?; + + Ok(SourceRecord { + id: SourceId::new(row.get::<_, String>(0)?), + name: row.get(1)?, + family_name: row.get(2)?, + style_name: row.get(3)?, + kind, + }) +} + +fn map_source_location_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(SourceAxisLocation { + source_id: SourceId::new(row.get::<_, String>(0)?), + axis_id: AxisId::new(row.get::<_, String>(1)?), + value: row.get(2)?, + }) +} diff --git a/crates/shift-store/src/store.rs b/crates/shift-store/src/store.rs new file mode 100644 index 00000000..398cc350 --- /dev/null +++ b/crates/shift-store/src/store.rs @@ -0,0 +1,3 @@ +pub struct ShiftStore { + pub(crate) conn: rusqlite::Connection, +} diff --git a/crates/shift-store/src/types.rs b/crates/shift-store/src/types.rs new file mode 100644 index 00000000..b9791716 --- /dev/null +++ b/crates/shift-store/src/types.rs @@ -0,0 +1,77 @@ +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct ComponentId(String); + +impl ComponentId { + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct AxisId(String); + +impl AxisId { + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct SourceId(String); + +impl SourceId { + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct GlyphId(String); + +impl GlyphId { + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct LayerId(String); + +impl LayerId { + pub fn new(id: impl Into) -> Self { + Self(id.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct RevisionId(i64); + +impl RevisionId { + pub fn new(id: i64) -> Self { + Self(id) + } + + pub fn get(self) -> i64 { + self.0 + } +} diff --git a/crates/shift-store/tests/store_test.rs b/crates/shift-store/tests/store_test.rs new file mode 100644 index 00000000..0b08de1c --- /dev/null +++ b/crates/shift-store/tests/store_test.rs @@ -0,0 +1,301 @@ +use shift_store::{ + AxisId, ComponentId, GlyphId, LayerId, NewAxis, NewGlyph, NewGlyphComponent, NewGlyphLayer, + NewSource, ShiftStore, SourceId, SourceKind, +}; + +#[test] +fn opens_memory_store() { + ShiftStore::open_memory_for_test().expect("memory store should open"); +} + +#[test] +fn creates_and_reads_glyph() { + let mut store = ShiftStore::open_memory_for_test().expect("memory store should open"); + let glyph_id = GlyphId::new("glyph-A"); + + store + .create_glyph(NewGlyph { + id: glyph_id.clone(), + name: Some("A".to_string()), + }) + .expect("glyph should be created"); + + let glyph = store + .get_glyph(&glyph_id) + .expect("glyph query should succeed") + .expect("glyph should exist"); + + assert_eq!(glyph.id, glyph_id); + assert_eq!(glyph.name.as_deref(), Some("A")); +} + +#[test] +fn creates_and_reads_axis() { + let mut store = ShiftStore::open_memory_for_test().expect("memory store should open"); + + let axis_id = create_weight_axis(&mut store); + + let axis = store + .get_axis(&axis_id) + .expect("axis query should succeed") + .expect("axis should exist"); + + assert_eq!(axis.id, axis_id); + assert_eq!(axis.tag, "wght"); + assert_eq!(axis.name, "Weight"); + assert_eq!(axis.min_value, 100.0); + assert_eq!(axis.default_value, 400.0); + assert_eq!(axis.max_value, 800.0); + assert!(!axis.hidden); +} + +#[test] +fn creates_and_reads_source() { + let mut store = ShiftStore::open_memory_for_test().expect("memory store should open"); + let source_id = create_regular_source(&mut store); + + let source = store + .get_source(&source_id) + .expect("source query should succeed") + .expect("source should exist"); + + assert_eq!(source.id, source_id); + assert_eq!(source.name.as_deref(), Some("Regular")); + assert_eq!(source.family_name.as_deref(), Some("Shift Sans")); + assert_eq!(source.style_name.as_deref(), Some("Regular")); + assert_eq!(source.kind, SourceKind::Master); +} + +#[test] +fn sets_and_reads_source_location() { + let mut store = ShiftStore::open_memory_for_test().expect("memory store should open"); + let axis_id = create_weight_axis(&mut store); + let source_id = create_regular_source(&mut store); + + store + .set_source_location(&source_id, &axis_id, 400.0) + .expect("source location should be set"); + + let locations = store + .get_source_locations(&source_id) + .expect("source locations query should succeed"); + + assert_eq!(locations.len(), 1); + assert_eq!(locations[0].source_id, source_id); + assert_eq!(locations[0].axis_id, axis_id); + assert_eq!(locations[0].value, 400.0); +} + +#[test] +fn source_location_requires_existing_source_and_axis() { + let mut store = ShiftStore::open_memory_for_test().expect("memory store should open"); + + let result = store.set_source_location( + &SourceId::new("source-missing"), + &AxisId::new("axis-wght"), + 400.0, + ); + + assert!(result.is_err()); +} + +#[test] +fn creates_and_reads_glyph_layer() { + let mut store = ShiftStore::open_memory_for_test().expect("memory store should open"); + let glyph_id = create_glyph_a(&mut store); + let source_id = create_regular_source(&mut store); + let layer_id = create_default_glyph_layer(&mut store, &glyph_id, &source_id); + + let layer = store + .get_glyph_layer(&layer_id) + .expect("glyph layer query should succeed") + .expect("glyph layer should exist"); + + assert_eq!(layer.id, layer_id); + assert_eq!(layer.glyph_id, glyph_id); + assert_eq!(layer.source_id, source_id); + assert_eq!(layer.name.as_deref(), Some("Regular")); +} + +#[test] +fn glyph_layer_requires_existing_glyph_and_source() { + let mut store = ShiftStore::open_memory_for_test().expect("memory store should open"); + + let result = store.create_glyph_layer(NewGlyphLayer { + id: LayerId::new("layer-A-regular"), + glyph_id: GlyphId::new("glyph-missing"), + source_id: SourceId::new("source-missing"), + name: Some("Regular".to_string()), + }); + + assert!(result.is_err()); +} + +#[test] +fn lists_glyph_layers_for_glyph() { + let mut store = ShiftStore::open_memory_for_test().expect("memory store should open"); + let glyph_id = create_glyph_a(&mut store); + let source_id = create_regular_source(&mut store); + let layer_id = create_default_glyph_layer(&mut store, &glyph_id, &source_id); + + let layers = store + .list_glyph_layers_for_glyph(&glyph_id) + .expect("glyph layers query should succeed"); + + assert_eq!(layers.len(), 1); + assert_eq!(layers[0].id, layer_id); + assert_eq!(layers[0].glyph_id, glyph_id); + assert_eq!(layers[0].source_id, source_id); +} + +#[test] +fn creates_and_reads_glyph_component() { + let mut store = ShiftStore::open_memory_for_test().expect("memory store should open"); + let glyph_id = create_glyph_a(&mut store); + let base_glyph_id = create_glyph_b(&mut store); + let source_id = create_regular_source(&mut store); + let layer_id = create_default_glyph_layer(&mut store, &glyph_id, &source_id); + let component_id = create_default_component(&mut store, &layer_id, &base_glyph_id); + + let component = store + .get_glyph_component(&component_id) + .expect("glyph component query should succeed") + .expect("glyph component should exist"); + + assert_eq!(component.id, component_id); + assert_eq!(component.layer_id, layer_id); + assert_eq!(component.base_glyph_id, base_glyph_id); + assert_eq!(component.order_index, 0); +} + +#[test] +fn glyph_component_requires_existing_layer_and_base_glyph() { + let mut store = ShiftStore::open_memory_for_test().expect("memory store should open"); + + let result = store.create_glyph_component(NewGlyphComponent { + id: ComponentId::new("component-A-B"), + layer_id: LayerId::new("layer-missing"), + base_glyph_id: GlyphId::new("glyph-missing"), + order_index: 0, + }); + + assert!(result.is_err()); +} + +#[test] +fn lists_glyph_components_for_layer() { + let mut store = ShiftStore::open_memory_for_test().expect("memory store should open"); + let glyph_id = create_glyph_a(&mut store); + let base_glyph_id = create_glyph_b(&mut store); + let source_id = create_regular_source(&mut store); + let layer_id = create_default_glyph_layer(&mut store, &glyph_id, &source_id); + let component_id = create_default_component(&mut store, &layer_id, &base_glyph_id); + + let components = store + .list_glyph_components_for_layer(&layer_id) + .expect("glyph components query should succeed"); + + assert_eq!(components.len(), 1); + assert_eq!(components[0].id, component_id); + assert_eq!(components[0].layer_id, layer_id); + assert_eq!(components[0].base_glyph_id, base_glyph_id); + assert_eq!(components[0].order_index, 0); +} + +fn create_glyph_a(store: &mut ShiftStore) -> GlyphId { + let glyph_id = GlyphId::new("glyph-A"); + + store + .create_glyph(NewGlyph { + id: glyph_id.clone(), + name: Some("A".to_string()), + }) + .expect("glyph should be created"); + + glyph_id +} + +fn create_glyph_b(store: &mut ShiftStore) -> GlyphId { + let glyph_id = GlyphId::new("glyph-B"); + + store + .create_glyph(NewGlyph { + id: glyph_id.clone(), + name: Some("B".to_string()), + }) + .expect("glyph should be created"); + + glyph_id +} + +fn create_default_glyph_layer( + store: &mut ShiftStore, + glyph_id: &GlyphId, + source_id: &SourceId, +) -> LayerId { + let layer_id = LayerId::new("layer-A-regular"); + + store + .create_glyph_layer(NewGlyphLayer { + id: layer_id.clone(), + glyph_id: glyph_id.clone(), + source_id: source_id.clone(), + name: Some("Regular".to_string()), + }) + .expect("glyph layer should be created"); + + layer_id +} + +fn create_default_component( + store: &mut ShiftStore, + layer_id: &LayerId, + base_glyph_id: &GlyphId, +) -> ComponentId { + let component_id = ComponentId::new("component-A-B"); + + store + .create_glyph_component(NewGlyphComponent { + id: component_id.clone(), + layer_id: layer_id.clone(), + base_glyph_id: base_glyph_id.clone(), + order_index: 0, + }) + .expect("glyph component should be created"); + + component_id +} + +fn create_weight_axis(store: &mut ShiftStore) -> AxisId { + let axis_id = AxisId::new("axis-wght"); + + store + .create_axis(NewAxis { + id: axis_id.clone(), + tag: "wght".to_string(), + name: "Weight".to_string(), + min_value: 100.0, + default_value: 400.0, + max_value: 800.0, + hidden: false, + }) + .expect("axis should be created"); + + axis_id +} + +fn create_regular_source(store: &mut ShiftStore) -> SourceId { + let source_id = SourceId::new("source-regular"); + + store + .create_source(NewSource { + id: source_id.clone(), + name: Some("Regular".to_string()), + family_name: Some("Shift Sans".to_string()), + style_name: Some("Regular".to_string()), + kind: SourceKind::Master, + }) + .expect("source should be created"); + + source_id +}