queryer merger test progress
This commit is contained in:
64
src/database/executors/mock.rs
Normal file
64
src/database/executors/mock.rs
Normal file
@ -0,0 +1,64 @@
|
||||
use crate::database::executors::DatabaseExecutor;
|
||||
use serde_json::Value;
|
||||
|
||||
#[cfg(test)]
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[cfg(test)]
|
||||
pub struct MockExecutor {
|
||||
pub query_responses: Mutex<Vec<Result<Value, String>>>,
|
||||
pub execute_responses: Mutex<Vec<Result<(), String>>>,
|
||||
pub captured_queries: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl MockExecutor {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
query_responses: Mutex::new(Vec::new()),
|
||||
execute_responses: Mutex::new(Vec::new()),
|
||||
captured_queries: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl DatabaseExecutor for MockExecutor {
|
||||
fn query(&self, sql: &str, _args: Option<&[Value]>) -> Result<Value, String> {
|
||||
println!("DEBUG SQL QUERY: {}", sql);
|
||||
self.captured_queries.lock().unwrap().push(sql.to_string());
|
||||
let mut responses = self.query_responses.lock().unwrap();
|
||||
if responses.is_empty() {
|
||||
return Ok(Value::Array(vec![]));
|
||||
}
|
||||
responses.remove(0)
|
||||
}
|
||||
|
||||
fn execute(&self, sql: &str, _args: Option<&[Value]>) -> Result<(), String> {
|
||||
println!("DEBUG SQL EXECUTE: {}", sql);
|
||||
self.captured_queries.lock().unwrap().push(sql.to_string());
|
||||
let mut responses = self.execute_responses.lock().unwrap();
|
||||
if responses.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
responses.remove(0)
|
||||
}
|
||||
|
||||
fn auth_user_id(&self) -> Result<String, String> {
|
||||
Ok("00000000-0000-0000-0000-000000000000".to_string())
|
||||
}
|
||||
|
||||
fn timestamp(&self) -> Result<String, String> {
|
||||
Ok("2026-03-10T00:00:00Z".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn get_queries(&self) -> Vec<String> {
|
||||
self.captured_queries.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn reset_mocks(&self) {
|
||||
self.captured_queries.lock().unwrap().clear();
|
||||
}
|
||||
}
|
||||
28
src/database/executors/mod.rs
Normal file
28
src/database/executors/mod.rs
Normal file
@ -0,0 +1,28 @@
|
||||
pub mod mock;
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub mod pgrx;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// An abstraction over database execution to allow for isolated unit testing
|
||||
/// without a live Postgres SPI connection.
|
||||
pub trait DatabaseExecutor: Send + Sync {
|
||||
/// Executes a query expecting a single JSONB return, representing rows.
|
||||
fn query(&self, sql: &str, args: Option<&[Value]>) -> Result<Value, String>;
|
||||
|
||||
/// Executes an operation (INSERT, UPDATE, DELETE, or pg_notify) that does not return rows.
|
||||
fn execute(&self, sql: &str, args: Option<&[Value]>) -> Result<(), String>;
|
||||
|
||||
/// Returns the current authenticated user's ID
|
||||
fn auth_user_id(&self) -> Result<String, String>;
|
||||
|
||||
/// Returns the current transaction timestamp
|
||||
fn timestamp(&self) -> Result<String, String>;
|
||||
|
||||
#[cfg(test)]
|
||||
fn get_queries(&self) -> Vec<String>;
|
||||
|
||||
#[cfg(test)]
|
||||
fn reset_mocks(&self);
|
||||
}
|
||||
@ -1,22 +1,7 @@
|
||||
use crate::database::executors::DatabaseExecutor;
|
||||
use pgrx::prelude::*;
|
||||
use serde_json::Value;
|
||||
|
||||
/// An abstraction over database execution to allow for isolated unit testing
|
||||
/// without a live Postgres SPI connection.
|
||||
pub trait DatabaseExecutor: Send + Sync {
|
||||
/// Executes a query expecting a single JSONB return, representing rows.
|
||||
fn query(&self, sql: &str, args: Option<&[Value]>) -> Result<Value, String>;
|
||||
|
||||
/// Executes an operation (INSERT, UPDATE, DELETE, or pg_notify) that does not return rows.
|
||||
fn execute(&self, sql: &str, args: Option<&[Value]>) -> Result<(), String>;
|
||||
|
||||
/// Returns the current authenticated user's ID
|
||||
fn auth_user_id(&self) -> Result<String, String>;
|
||||
|
||||
/// Returns the current transaction timestamp
|
||||
fn timestamp(&self) -> Result<String, String>;
|
||||
}
|
||||
|
||||
/// The production executor that wraps `pgrx::spi::Spi`.
|
||||
pub struct SpiExecutor;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
pub mod r#enum;
|
||||
pub mod executor;
|
||||
pub mod executors;
|
||||
pub mod formats;
|
||||
pub mod page;
|
||||
pub mod punc;
|
||||
@ -7,14 +7,26 @@ pub mod relation;
|
||||
pub mod schema;
|
||||
pub mod r#type;
|
||||
|
||||
use crate::database::r#enum::Enum;
|
||||
use crate::database::executor::{DatabaseExecutor, SpiExecutor};
|
||||
use crate::database::punc::{Punc, Stem};
|
||||
use crate::database::relation::Relation;
|
||||
use crate::database::schema::Schema;
|
||||
use crate::database::r#type::Type;
|
||||
// External mock exports inside the executor sub-folder
|
||||
|
||||
use r#enum::Enum;
|
||||
use executors::DatabaseExecutor;
|
||||
|
||||
#[cfg(not(test))]
|
||||
use executors::pgrx::SpiExecutor;
|
||||
|
||||
#[cfg(test)]
|
||||
use executors::mock::MockExecutor;
|
||||
|
||||
pub mod stem;
|
||||
use punc::Punc;
|
||||
use relation::Relation;
|
||||
use schema::Schema;
|
||||
use serde_json::Value;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use stem::Stem;
|
||||
use r#type::Type;
|
||||
|
||||
pub struct Database {
|
||||
pub enums: HashMap<String, Enum>,
|
||||
@ -22,22 +34,28 @@ pub struct Database {
|
||||
pub puncs: HashMap<String, Punc>,
|
||||
pub relations: HashMap<String, Relation>,
|
||||
pub schemas: HashMap<String, Schema>,
|
||||
// Map of Schema ID -> { Entity Type -> Target Subschema Arc }
|
||||
pub stems: HashMap<String, HashMap<String, Arc<Stem>>>,
|
||||
pub descendants: HashMap<String, Vec<String>>,
|
||||
pub depths: HashMap<String, usize>,
|
||||
pub executor: Box<dyn DatabaseExecutor + Send + Sync>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub fn new(val: &serde_json::Value) -> Self {
|
||||
pub fn new(val: &serde_json::Value) -> Result<Self, crate::drop::Drop> {
|
||||
let mut db = Self {
|
||||
enums: HashMap::new(),
|
||||
types: HashMap::new(),
|
||||
relations: HashMap::new(),
|
||||
puncs: HashMap::new(),
|
||||
schemas: HashMap::new(),
|
||||
stems: HashMap::new(),
|
||||
descendants: HashMap::new(),
|
||||
depths: HashMap::new(),
|
||||
#[cfg(not(test))]
|
||||
executor: Box::new(SpiExecutor::new()),
|
||||
#[cfg(test)]
|
||||
executor: Box::new(MockExecutor::new()),
|
||||
};
|
||||
|
||||
if let Some(arr) = val.get("enums").and_then(|v| v.as_array()) {
|
||||
@ -86,8 +104,8 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
let _ = db.compile();
|
||||
db
|
||||
db.compile()?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// Override the default executor for unit testing
|
||||
@ -117,12 +135,12 @@ impl Database {
|
||||
}
|
||||
|
||||
/// Organizes the graph of the database, compiling regex, format functions, and caching relationships.
|
||||
fn compile(&mut self) -> Result<(), String> {
|
||||
pub fn compile(&mut self) -> Result<(), crate::drop::Drop> {
|
||||
self.collect_schemas();
|
||||
self.collect_depths();
|
||||
self.collect_descendants();
|
||||
self.compile_schemas();
|
||||
self.collect_stems();
|
||||
self.collect_stems()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -229,88 +247,78 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_stems(&mut self) {
|
||||
let mut st_map: HashMap<String, Vec<Stem>> = HashMap::new();
|
||||
for (name, _) in &self.puncs {
|
||||
let mut stems = Vec::new();
|
||||
let response_id = format!("{}.response", name);
|
||||
if let Some(resp_schema) = self.schemas.get(&response_id) {
|
||||
fn collect_stems(&mut self) -> Result<(), crate::drop::Drop> {
|
||||
let mut db_stems: HashMap<String, HashMap<String, Arc<Stem>>> = HashMap::new();
|
||||
let mut errors: Vec<crate::drop::Error> = Vec::new();
|
||||
|
||||
let schema_ids: Vec<String> = self.schemas.keys().cloned().collect();
|
||||
for schema_id in schema_ids {
|
||||
if let Some(schema) = self.schemas.get(&schema_id) {
|
||||
let mut inner_map = HashMap::new();
|
||||
Self::discover_stems(
|
||||
&self.types,
|
||||
&self.schemas,
|
||||
&self.relations,
|
||||
&response_id,
|
||||
resp_schema,
|
||||
self,
|
||||
&schema_id,
|
||||
schema,
|
||||
String::from(""),
|
||||
None,
|
||||
None,
|
||||
&mut stems,
|
||||
&mut inner_map,
|
||||
&mut errors,
|
||||
);
|
||||
}
|
||||
st_map.insert(name.clone(), stems);
|
||||
}
|
||||
for (name, stems) in st_map {
|
||||
if let Some(p) = self.puncs.get_mut(&name) {
|
||||
p.stems = stems;
|
||||
if !inner_map.is_empty() {
|
||||
println!("SCHEMA: {} STEMS: {:?}", schema_id, inner_map.keys());
|
||||
db_stems.insert(schema_id, inner_map);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.stems = db_stems;
|
||||
|
||||
if !errors.is_empty() {
|
||||
return Err(crate::drop::Drop::with_errors(errors));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn discover_stems(
|
||||
types: &HashMap<String, Type>,
|
||||
schemas: &HashMap<String, Schema>,
|
||||
relations: &HashMap<String, Relation>,
|
||||
_schema_id: &str,
|
||||
db: &Database,
|
||||
root_schema_id: &str,
|
||||
schema: &Schema,
|
||||
current_path: String,
|
||||
mut current_path: String,
|
||||
parent_type: Option<String>,
|
||||
property_name: Option<String>,
|
||||
stems: &mut Vec<Stem>,
|
||||
inner_map: &mut HashMap<String, Arc<Stem>>,
|
||||
errors: &mut Vec<crate::drop::Error>,
|
||||
) {
|
||||
let mut is_entity = false;
|
||||
let mut entity_type = String::new();
|
||||
|
||||
// Check if this schema resolves to an Entity
|
||||
let mut current_ref = schema.obj.r#ref.clone();
|
||||
let mut depth = 0;
|
||||
while let Some(r) = current_ref {
|
||||
if types.contains_key(&r) {
|
||||
is_entity = true;
|
||||
entity_type = r.clone();
|
||||
break;
|
||||
}
|
||||
if let Some(s) = schemas.get(&r) {
|
||||
current_ref = s.obj.r#ref.clone();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
depth += 1;
|
||||
if depth > 20 {
|
||||
break;
|
||||
} // prevent infinite loop
|
||||
let mut examine_id = None;
|
||||
if let Some(ref r) = schema.obj.r#ref {
|
||||
examine_id = Some(r.clone());
|
||||
} else if let Some(ref id) = schema.obj.id {
|
||||
examine_id = Some(id.clone());
|
||||
}
|
||||
|
||||
if is_entity {
|
||||
let final_path = if current_path.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
current_path.clone()
|
||||
};
|
||||
if let Some(target) = examine_id {
|
||||
let parts: Vec<&str> = target.split('.').collect();
|
||||
if let Some(last_seg) = parts.last() {
|
||||
if db.types.contains_key(*last_seg) {
|
||||
is_entity = true;
|
||||
entity_type = last_seg.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut relation_col = None;
|
||||
let mut relation_col = None;
|
||||
if is_entity {
|
||||
if let (Some(pt), Some(prop)) = (&parent_type, &property_name) {
|
||||
let expected_col = format!("{}_id", prop);
|
||||
let mut found = false;
|
||||
|
||||
// Try to find the exact relation from the database schema
|
||||
for rel in relations.values() {
|
||||
if rel.source_type == *pt && rel.destination_type == entity_type {
|
||||
if rel.source_columns.contains(&expected_col) {
|
||||
relation_col = Some(expected_col.clone());
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
} else if rel.source_type == entity_type && rel.destination_type == *pt {
|
||||
for rel in db.relations.values() {
|
||||
if (rel.source_type == *pt && rel.destination_type == entity_type)
|
||||
|| (rel.source_type == entity_type && rel.destination_type == *pt)
|
||||
{
|
||||
if rel.source_columns.contains(&expected_col) {
|
||||
relation_col = Some(expected_col.clone());
|
||||
found = true;
|
||||
@ -318,64 +326,128 @@ impl Database {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
// Fallback guess if explicit matching fails
|
||||
relation_col = Some(expected_col);
|
||||
}
|
||||
}
|
||||
|
||||
stems.push(Stem {
|
||||
path: final_path,
|
||||
let stem = Stem {
|
||||
r#type: entity_type.clone(),
|
||||
relation: relation_col,
|
||||
});
|
||||
schema: Arc::new(schema.clone()),
|
||||
};
|
||||
|
||||
let mut branch_path = current_path.clone();
|
||||
if !current_path.is_empty() {
|
||||
branch_path = format!("{}/{}", current_path, entity_type);
|
||||
}
|
||||
|
||||
if inner_map.contains_key(&branch_path) {
|
||||
errors.push(crate::drop::Error {
|
||||
code: "STEM_COLLISION".to_string(),
|
||||
message: format!("The stem path `{}` resolves to multiple Entity boundaries. This usually occurs during un-wrapped $family or oneOf polymorphic schemas where multiple Entities are directly assigned to the same property. To fix this, encapsulate the polymorphic branch.", branch_path),
|
||||
details: crate::drop::ErrorDetails {
|
||||
path: root_schema_id.to_string(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
inner_map.insert(branch_path.clone(), Arc::new(stem));
|
||||
|
||||
// Update current_path for structural children
|
||||
current_path = branch_path;
|
||||
}
|
||||
|
||||
// Pass the new parent downwards
|
||||
let next_parent = if is_entity {
|
||||
Some(entity_type.clone())
|
||||
} else {
|
||||
parent_type.clone()
|
||||
};
|
||||
|
||||
// Properties branch
|
||||
if let Some(props) = &schema.obj.properties {
|
||||
for (k, v) in props {
|
||||
let next_path = format!(
|
||||
"{}/{}",
|
||||
if current_path.is_empty() {
|
||||
""
|
||||
} else {
|
||||
¤t_path
|
||||
},
|
||||
k
|
||||
);
|
||||
// Bypass target and source properties if we are in a relationship
|
||||
if let Some(parent_str) = &next_parent {
|
||||
if let Some(pt) = db.types.get(parent_str) {
|
||||
if pt.relationship && (k == "target" || k == "source") {
|
||||
Self::discover_stems(
|
||||
db,
|
||||
root_schema_id,
|
||||
v,
|
||||
current_path.clone(),
|
||||
next_parent.clone(),
|
||||
Some(k.clone()),
|
||||
inner_map,
|
||||
errors,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Standard Property Pathing
|
||||
let next_path = if current_path.is_empty() {
|
||||
k.clone()
|
||||
} else {
|
||||
format!("{}/{}", current_path, k)
|
||||
};
|
||||
|
||||
Self::discover_stems(
|
||||
types,
|
||||
schemas,
|
||||
relations,
|
||||
"",
|
||||
db,
|
||||
root_schema_id,
|
||||
v,
|
||||
next_path,
|
||||
next_parent.clone(),
|
||||
Some(k.clone()),
|
||||
stems,
|
||||
inner_map,
|
||||
errors,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Array Item branch
|
||||
if let Some(items) = &schema.obj.items {
|
||||
Self::discover_stems(
|
||||
types,
|
||||
schemas,
|
||||
relations,
|
||||
"",
|
||||
db,
|
||||
root_schema_id,
|
||||
items,
|
||||
current_path.clone(),
|
||||
next_parent.clone(),
|
||||
property_name.clone(),
|
||||
stems,
|
||||
inner_map,
|
||||
errors,
|
||||
);
|
||||
}
|
||||
|
||||
// Polymorphism branch
|
||||
if let Some(arr) = &schema.obj.one_of {
|
||||
for v in arr {
|
||||
Self::discover_stems(
|
||||
db,
|
||||
root_schema_id,
|
||||
v.as_ref(),
|
||||
current_path.clone(),
|
||||
next_parent.clone(),
|
||||
property_name.clone(),
|
||||
inner_map,
|
||||
errors,
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(arr) = &schema.obj.all_of {
|
||||
for v in arr {
|
||||
Self::discover_stems(
|
||||
db,
|
||||
root_schema_id,
|
||||
v.as_ref(),
|
||||
current_path.clone(),
|
||||
next_parent.clone(),
|
||||
property_name.clone(),
|
||||
inner_map,
|
||||
errors,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,14 +2,6 @@ use crate::database::page::Page;
|
||||
use crate::database::schema::Schema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Stem {
|
||||
pub path: String,
|
||||
pub r#type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub relation: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct Punc {
|
||||
@ -25,6 +17,4 @@ pub struct Punc {
|
||||
pub page: Option<Page>,
|
||||
#[serde(default)]
|
||||
pub schemas: Vec<Schema>,
|
||||
#[serde(default)]
|
||||
pub stems: Vec<Stem>,
|
||||
}
|
||||
|
||||
@ -5,6 +5,14 @@ use std::sync::Arc;
|
||||
|
||||
// Schema mirrors the Go Punc Generator's schema struct for consistency.
|
||||
// It is an order-preserving representation of a JSON Schema.
|
||||
|
||||
pub fn deserialize_some<'de, D>(deserializer: D) -> Result<Option<Value>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let v = Value::deserialize(deserializer)?;
|
||||
Ok(Some(v))
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SchemaObject {
|
||||
// Core Schema Keywords
|
||||
@ -79,7 +87,7 @@ pub struct SchemaObject {
|
||||
#[serde(
|
||||
default,
|
||||
rename = "const",
|
||||
deserialize_with = "crate::validator::util::deserialize_some"
|
||||
deserialize_with = "crate::database::schema::deserialize_some"
|
||||
)]
|
||||
pub const_: Option<Value>,
|
||||
|
||||
@ -96,8 +104,6 @@ pub struct SchemaObject {
|
||||
// Combining Keywords
|
||||
#[serde(rename = "allOf")]
|
||||
pub all_of: Option<Vec<Arc<Schema>>>,
|
||||
#[serde(rename = "anyOf")]
|
||||
pub any_of: Option<Vec<Arc<Schema>>>,
|
||||
#[serde(rename = "oneOf")]
|
||||
pub one_of: Option<Vec<Arc<Schema>>>,
|
||||
#[serde(rename = "not")]
|
||||
@ -238,9 +244,6 @@ impl Schema {
|
||||
if let Some(arr) = &mut self.obj.all_of {
|
||||
map_arr(arr);
|
||||
}
|
||||
if let Some(arr) = &mut self.obj.any_of {
|
||||
map_arr(arr);
|
||||
}
|
||||
if let Some(arr) = &mut self.obj.one_of {
|
||||
map_arr(arr);
|
||||
}
|
||||
@ -300,7 +303,6 @@ impl<'de> Deserialize<'de> for Schema {
|
||||
&& obj.enum_.is_none()
|
||||
&& obj.const_.is_none()
|
||||
&& obj.all_of.is_none()
|
||||
&& obj.any_of.is_none()
|
||||
&& obj.one_of.is_none()
|
||||
&& obj.not.is_none()
|
||||
&& obj.if_.is_none()
|
||||
|
||||
17
src/database/stem.rs
Normal file
17
src/database/stem.rs
Normal file
@ -0,0 +1,17 @@
|
||||
use crate::database::schema::Schema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Stem {
|
||||
pub r#type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub relation: Option<String>,
|
||||
|
||||
// The actual database schema node mapping for
|
||||
// O(1) jump table execution for queryer.
|
||||
//
|
||||
// Automatically skipped from `jspg_stems()` JSON payload output.
|
||||
#[serde(skip)]
|
||||
pub schema: Arc<Schema>,
|
||||
}
|
||||
@ -12,18 +12,18 @@ pub struct Jspg {
|
||||
}
|
||||
|
||||
impl Jspg {
|
||||
pub fn new(database_val: &serde_json::Value) -> Self {
|
||||
let database_instance = Database::new(database_val);
|
||||
pub fn new(database_val: &serde_json::Value) -> Result<Self, crate::drop::Drop> {
|
||||
let database_instance = Database::new(database_val)?;
|
||||
let database = Arc::new(database_instance);
|
||||
let validator = Validator::new(database.clone());
|
||||
let queryer = Queryer::new(database.clone());
|
||||
let merger = Merger::new(database.clone());
|
||||
|
||||
Self {
|
||||
Ok(Self {
|
||||
database,
|
||||
validator,
|
||||
queryer,
|
||||
merger,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
183
src/lib.rs
183
src/lib.rs
@ -1,7 +1,12 @@
|
||||
#[cfg(not(test))]
|
||||
use pgrx::*;
|
||||
|
||||
#[cfg(not(test))]
|
||||
pg_module_magic!();
|
||||
|
||||
#[cfg(test)]
|
||||
pub struct JsonB(pub serde_json::Value);
|
||||
|
||||
pub mod database;
|
||||
pub mod drop;
|
||||
pub mod jspg;
|
||||
@ -20,22 +25,38 @@ lazy_static::lazy_static! {
|
||||
static ref GLOBAL_JSPG: RwLock<Option<Arc<jspg::Jspg>>> = RwLock::new(None);
|
||||
}
|
||||
|
||||
#[pg_extern(strict)]
|
||||
pub fn jspg_setup(database: JsonB) -> JsonB {
|
||||
let new_jspg = crate::jspg::Jspg::new(&database.0);
|
||||
let new_arc = Arc::new(new_jspg);
|
||||
|
||||
// 3. ATOMIC SWAP
|
||||
{
|
||||
let mut lock = GLOBAL_JSPG.write().unwrap();
|
||||
*lock = Some(new_arc);
|
||||
}
|
||||
|
||||
let drop = crate::drop::Drop::success();
|
||||
fn jspg_failure() -> JsonB {
|
||||
let error = crate::drop::Error {
|
||||
code: "ENGINE_NOT_INITIALIZED".to_string(),
|
||||
message: "JSPG extension has not been initialized via jspg_setup".to_string(),
|
||||
details: crate::drop::ErrorDetails {
|
||||
path: "".to_string(),
|
||||
},
|
||||
};
|
||||
let drop = crate::drop::Drop::with_errors(vec![error]);
|
||||
JsonB(serde_json::to_value(drop).unwrap())
|
||||
}
|
||||
|
||||
#[pg_extern]
|
||||
#[cfg_attr(not(test), pg_extern(strict))]
|
||||
pub fn jspg_setup(database: JsonB) -> JsonB {
|
||||
match crate::jspg::Jspg::new(&database.0) {
|
||||
Ok(new_jspg) => {
|
||||
let new_arc = Arc::new(new_jspg);
|
||||
|
||||
// 3. ATOMIC SWAP
|
||||
{
|
||||
let mut lock = GLOBAL_JSPG.write().unwrap();
|
||||
*lock = Some(new_arc);
|
||||
}
|
||||
|
||||
let drop = crate::drop::Drop::success();
|
||||
JsonB(serde_json::to_value(drop).unwrap())
|
||||
}
|
||||
Err(drop) => JsonB(serde_json::to_value(drop).unwrap()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), pg_extern)]
|
||||
pub fn jspg_merge(data: JsonB) -> JsonB {
|
||||
// Try to acquire a read lock to get a clone of the Engine Arc
|
||||
let engine_opt = {
|
||||
@ -44,35 +65,15 @@ pub fn jspg_merge(data: JsonB) -> JsonB {
|
||||
};
|
||||
|
||||
match engine_opt {
|
||||
Some(engine) => match engine.merger.merge(data.0) {
|
||||
Ok(result) => JsonB(result),
|
||||
Err(e) => {
|
||||
let error = crate::drop::Error {
|
||||
code: "MERGE_FAILED".to_string(),
|
||||
message: e,
|
||||
details: crate::drop::ErrorDetails {
|
||||
path: "".to_string(),
|
||||
},
|
||||
};
|
||||
let drop = crate::drop::Drop::with_errors(vec![error]);
|
||||
JsonB(serde_json::to_value(drop).unwrap())
|
||||
}
|
||||
},
|
||||
None => {
|
||||
let error = crate::drop::Error {
|
||||
code: "VALIDATOR_NOT_INITIALIZED".to_string(),
|
||||
message: "The JSPG database has not been cached yet. Run jspg_setup()".to_string(),
|
||||
details: crate::drop::ErrorDetails {
|
||||
path: "".to_string(),
|
||||
},
|
||||
};
|
||||
let drop = crate::drop::Drop::with_errors(vec![error]);
|
||||
Some(engine) => {
|
||||
let drop = engine.merger.merge(data.0);
|
||||
JsonB(serde_json::to_value(drop).unwrap())
|
||||
}
|
||||
None => jspg_failure(),
|
||||
}
|
||||
}
|
||||
|
||||
#[pg_extern]
|
||||
#[cfg_attr(not(test), pg_extern)]
|
||||
pub fn jspg_query(schema_id: &str, stem: Option<&str>, filters: Option<JsonB>) -> JsonB {
|
||||
let engine_opt = {
|
||||
let lock = GLOBAL_JSPG.read().unwrap();
|
||||
@ -80,38 +81,19 @@ pub fn jspg_query(schema_id: &str, stem: Option<&str>, filters: Option<JsonB>) -
|
||||
};
|
||||
|
||||
match engine_opt {
|
||||
Some(engine) => match engine
|
||||
.queryer
|
||||
.query(schema_id, stem, filters.as_ref().map(|f| &f.0))
|
||||
{
|
||||
Ok(res) => JsonB(res),
|
||||
Err(e) => {
|
||||
let error = crate::drop::Error {
|
||||
code: "QUERY_FAILED".to_string(),
|
||||
message: e,
|
||||
details: crate::drop::ErrorDetails {
|
||||
path: schema_id.to_string(),
|
||||
},
|
||||
};
|
||||
JsonB(serde_json::to_value(crate::drop::Drop::with_errors(vec![error])).unwrap())
|
||||
}
|
||||
},
|
||||
None => {
|
||||
let error = crate::drop::Error {
|
||||
code: "ENGINE_NOT_INITIALIZED".to_string(),
|
||||
message: "JSPG extension has not been initialized via jspg_setup".to_string(),
|
||||
details: crate::drop::ErrorDetails {
|
||||
path: "".to_string(),
|
||||
},
|
||||
};
|
||||
JsonB(serde_json::to_value(crate::drop::Drop::with_errors(vec![error])).unwrap())
|
||||
Some(engine) => {
|
||||
let drop = engine
|
||||
.queryer
|
||||
.query(schema_id, stem, filters.as_ref().map(|f| &f.0));
|
||||
JsonB(serde_json::to_value(drop).unwrap())
|
||||
}
|
||||
None => jspg_failure(),
|
||||
}
|
||||
}
|
||||
|
||||
// `mask_json_schema` has been removed as the mask architecture is fully replaced by Spi string queries during DB interactions.
|
||||
|
||||
#[pg_extern(strict, parallel_safe)]
|
||||
#[cfg_attr(not(test), pg_extern(strict, parallel_safe))]
|
||||
pub fn jspg_validate(schema_id: &str, instance: JsonB) -> JsonB {
|
||||
// 1. Acquire Snapshot
|
||||
let jspg_arc = {
|
||||
@ -121,50 +103,17 @@ pub fn jspg_validate(schema_id: &str, instance: JsonB) -> JsonB {
|
||||
|
||||
// 2. Validate (Lock-Free)
|
||||
if let Some(engine) = jspg_arc {
|
||||
match engine.validator.validate(schema_id, &instance.0) {
|
||||
Ok(result) => {
|
||||
if result.is_valid() {
|
||||
let drop = crate::drop::Drop::success();
|
||||
JsonB(serde_json::to_value(drop).unwrap())
|
||||
} else {
|
||||
let errors: Vec<crate::drop::Error> = result
|
||||
.errors
|
||||
.into_iter()
|
||||
.map(|e| crate::drop::Error {
|
||||
code: e.code,
|
||||
message: e.message,
|
||||
details: crate::drop::ErrorDetails { path: e.path },
|
||||
})
|
||||
.collect();
|
||||
let drop = crate::drop::Drop::with_errors(errors);
|
||||
JsonB(serde_json::to_value(drop).unwrap())
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let error = crate::drop::Error {
|
||||
code: e.code,
|
||||
message: e.message,
|
||||
details: crate::drop::ErrorDetails { path: e.path },
|
||||
};
|
||||
let drop = crate::drop::Drop::with_errors(vec![error]);
|
||||
JsonB(serde_json::to_value(drop).unwrap())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let error = crate::drop::Error {
|
||||
code: "VALIDATOR_NOT_INITIALIZED".to_string(),
|
||||
message: "The JSPG database has not been cached yet. Run jspg_setup()".to_string(),
|
||||
details: crate::drop::ErrorDetails {
|
||||
path: "".to_string(),
|
||||
},
|
||||
};
|
||||
let drop = crate::drop::Drop::with_errors(vec![error]);
|
||||
let drop = engine.validator.validate(schema_id, &instance.0);
|
||||
JsonB(serde_json::to_value(drop).unwrap())
|
||||
} else {
|
||||
jspg_failure()
|
||||
}
|
||||
}
|
||||
|
||||
#[pg_extern]
|
||||
pub fn jspg_get_punc_stems(punc_name: &str) -> JsonB {
|
||||
#[cfg_attr(not(test), pg_extern)]
|
||||
pub fn jspg_stems() -> JsonB {
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
let engine_opt = {
|
||||
let lock = GLOBAL_JSPG.read().unwrap();
|
||||
lock.clone()
|
||||
@ -172,17 +121,13 @@ pub fn jspg_get_punc_stems(punc_name: &str) -> JsonB {
|
||||
|
||||
match engine_opt {
|
||||
Some(engine) => {
|
||||
if let Some(punc) = engine.database.puncs.get(punc_name) {
|
||||
JsonB(serde_json::to_value(&punc.stems).unwrap_or(serde_json::Value::Array(vec![])))
|
||||
} else {
|
||||
JsonB(serde_json::Value::Array(vec![]))
|
||||
}
|
||||
JsonB(serde_json::to_value(&engine.database.stems).unwrap_or(Value::Object(Map::new())))
|
||||
}
|
||||
None => JsonB(serde_json::Value::Array(vec![])),
|
||||
None => JsonB(Value::Object(Map::new())),
|
||||
}
|
||||
}
|
||||
|
||||
#[pg_extern(strict)]
|
||||
#[cfg_attr(not(test), pg_extern(strict))]
|
||||
pub fn jspg_teardown() -> JsonB {
|
||||
let mut lock = GLOBAL_JSPG.write().unwrap();
|
||||
*lock = None;
|
||||
@ -190,21 +135,5 @@ pub fn jspg_teardown() -> JsonB {
|
||||
JsonB(serde_json::to_value(drop).unwrap())
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "pg_test"))]
|
||||
#[pg_schema]
|
||||
mod tests {
|
||||
use pgrx::prelude::*;
|
||||
include!("tests/fixtures.rs");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod pg_test {
|
||||
pub fn setup(_options: Vec<&str>) {
|
||||
// perform any initialization common to all tests
|
||||
}
|
||||
|
||||
pub fn postgresql_conf_options() -> Vec<&'static str> {
|
||||
// return any postgresql.conf settings that are required for your tests
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
pub mod tests;
|
||||
|
||||
@ -21,8 +21,21 @@ impl Merger {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn merge(&self, data: Value) -> crate::drop::Drop {
|
||||
match self.merge_internal(data) {
|
||||
Ok(val) => crate::drop::Drop::success_with_val(val),
|
||||
Err(msg) => crate::drop::Drop::with_errors(vec![crate::drop::Error {
|
||||
code: "MERGE_FAILED".to_string(),
|
||||
message: msg,
|
||||
details: crate::drop::ErrorDetails {
|
||||
path: "".to_string(),
|
||||
},
|
||||
}]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Primary recursive entrypoint that separates Array lists from Object branches
|
||||
pub fn merge(&self, data: Value) -> Result<Value, String> {
|
||||
pub(crate) fn merge_internal(&self, data: Value) -> Result<Value, String> {
|
||||
let result = match data {
|
||||
Value::Array(items) => self.merge_array(items)?,
|
||||
Value::Object(map) => self.merge_object(map)?,
|
||||
@ -62,7 +75,7 @@ impl Merger {
|
||||
let mut resolved_items = Vec::new();
|
||||
for item in items {
|
||||
// Recursively evaluate each object in the array
|
||||
let resolved = self.merge(item)?;
|
||||
let resolved = self.merge_internal(item)?;
|
||||
resolved_items.push(resolved);
|
||||
}
|
||||
Ok(Value::Array(resolved_items))
|
||||
@ -178,7 +191,7 @@ impl Merger {
|
||||
}
|
||||
|
||||
// RECURSE: Merge the modified children
|
||||
let merged_val = self.merge(child_val)?;
|
||||
let merged_val = self.merge_internal(child_val)?;
|
||||
|
||||
// Post-Process: Apply relations upwards if parent owns the FK
|
||||
if let Some((relation, parent_is_source, _child_is_source)) = relation_info {
|
||||
@ -653,6 +666,8 @@ impl Merger {
|
||||
}
|
||||
}
|
||||
|
||||
my_changes.sort();
|
||||
|
||||
if is_update {
|
||||
if my_changes.is_empty() {
|
||||
continue;
|
||||
|
||||
@ -23,86 +23,34 @@ impl SqlCompiler {
|
||||
.get(schema_id)
|
||||
.ok_or_else(|| format!("Schema not found: {}", schema_id))?;
|
||||
|
||||
let resolved_arc;
|
||||
let target_schema = if let Some(path) = stem_path.filter(|p| !p.is_empty() && *p != "/") {
|
||||
self.resolve_stem(schema, path)?
|
||||
if let Some(stems_map) = self.db.stems.get(schema_id) {
|
||||
if let Some(stem) = stems_map.get(path) {
|
||||
resolved_arc = stem.schema.clone();
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Stem entity type '{}' not found in schema '{}'",
|
||||
path, schema_id
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Stem entity type '{}' not found in schema '{}'",
|
||||
path, schema_id
|
||||
));
|
||||
}
|
||||
resolved_arc.as_ref()
|
||||
} else {
|
||||
schema
|
||||
};
|
||||
|
||||
// 1. We expect the top level to typically be an Object or Array
|
||||
let (sql, _) = self.walk_schema(target_schema, "t1", None, filter_keys)?;
|
||||
// We expect the top level to typically be an Object or Array
|
||||
let is_stem_query = stem_path.is_some();
|
||||
let (sql, _) = self.walk_schema(target_schema, "t1", None, filter_keys, is_stem_query, 0)?;
|
||||
Ok(sql)
|
||||
}
|
||||
|
||||
fn resolve_stem<'a>(
|
||||
&'a self,
|
||||
mut schema: &'a crate::database::schema::Schema,
|
||||
path: &str,
|
||||
) -> Result<&'a crate::database::schema::Schema, String> {
|
||||
let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
|
||||
for part in parts {
|
||||
let mut current = schema;
|
||||
let mut depth = 0;
|
||||
while let Some(r) = ¤t.obj.r#ref {
|
||||
if let Some(s) = self.db.schemas.get(r) {
|
||||
current = s;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
depth += 1;
|
||||
if depth > 20 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if current.obj.properties.is_none() && current.obj.items.is_some() {
|
||||
if let Some(items) = ¤t.obj.items {
|
||||
current = items;
|
||||
let mut depth2 = 0;
|
||||
while let Some(r) = ¤t.obj.r#ref {
|
||||
if let Some(s) = self.db.schemas.get(r) {
|
||||
current = s;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
depth2 += 1;
|
||||
if depth2 > 20 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(props) = ¤t.obj.properties {
|
||||
if let Some(next_schema) = props.get(part) {
|
||||
schema = next_schema;
|
||||
} else {
|
||||
return Err(format!("Stem part '{}' not found in schema", part));
|
||||
}
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Cannot resolve stem part '{}': not an object",
|
||||
part
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let mut current = schema;
|
||||
let mut depth = 0;
|
||||
while let Some(r) = ¤t.obj.r#ref {
|
||||
if let Some(s) = self.db.schemas.get(r) {
|
||||
current = s;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
depth += 1;
|
||||
if depth > 20 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(current)
|
||||
}
|
||||
|
||||
/// Recursively walks the schema AST emitting native PostgreSQL jsonb mapping
|
||||
/// Returns a tuple of (SQL_String, Field_Type)
|
||||
fn walk_schema(
|
||||
@ -111,6 +59,8 @@ impl SqlCompiler {
|
||||
parent_alias: &str,
|
||||
prop_name_context: Option<&str>,
|
||||
filter_keys: &[String],
|
||||
is_stem_query: bool,
|
||||
depth: usize,
|
||||
) -> Result<(String, String), String> {
|
||||
// Determine the base schema type (could be an array, object, or literal)
|
||||
match &schema.obj.type_ {
|
||||
@ -119,6 +69,9 @@ impl SqlCompiler {
|
||||
if let Some(items) = &schema.obj.items {
|
||||
if let Some(ref_id) = &items.obj.r#ref {
|
||||
if let Some(type_def) = self.db.types.get(ref_id) {
|
||||
if is_stem_query && depth > 0 {
|
||||
return Ok(("".to_string(), "abort".to_string()));
|
||||
}
|
||||
return self.compile_entity_node(
|
||||
items,
|
||||
type_def,
|
||||
@ -126,11 +79,19 @@ impl SqlCompiler {
|
||||
prop_name_context,
|
||||
true,
|
||||
filter_keys,
|
||||
is_stem_query,
|
||||
depth,
|
||||
);
|
||||
}
|
||||
}
|
||||
let (item_sql, _) =
|
||||
self.walk_schema(items, parent_alias, prop_name_context, filter_keys)?;
|
||||
let (item_sql, _) = self.walk_schema(
|
||||
items,
|
||||
parent_alias,
|
||||
prop_name_context,
|
||||
filter_keys,
|
||||
is_stem_query,
|
||||
depth + 1,
|
||||
)?;
|
||||
return Ok((
|
||||
format!("(SELECT jsonb_agg({}) FROM TODO)", item_sql),
|
||||
"array".to_string(),
|
||||
@ -143,29 +104,57 @@ impl SqlCompiler {
|
||||
))
|
||||
}
|
||||
_ => {
|
||||
// Handle Objects & Direct Refs
|
||||
if let Some(ref_id) = &schema.obj.r#ref {
|
||||
// If it's a $ref, check if it points to an Entity Type
|
||||
if let Some(type_def) = self.db.types.get(ref_id) {
|
||||
return self.compile_entity_node(
|
||||
schema,
|
||||
type_def,
|
||||
parent_alias,
|
||||
prop_name_context,
|
||||
false,
|
||||
filter_keys,
|
||||
);
|
||||
// Determine if this schema represents a Database Entity
|
||||
let mut resolved_type = None;
|
||||
|
||||
// Target is generally a specific schema (e.g. 'base.person'), but it tells us what physical
|
||||
// database table hierarchy it maps to via the `schema.id` prefix/suffix convention.
|
||||
if let Some(lookup_key) = schema.obj.id.as_ref().or(schema.obj.r#ref.as_ref()) {
|
||||
let base_type_name = lookup_key.split('.').next_back().unwrap_or("").to_string();
|
||||
resolved_type = self.db.types.get(&base_type_name);
|
||||
}
|
||||
|
||||
if let Some(type_def) = resolved_type {
|
||||
if is_stem_query && depth > 0 {
|
||||
return Ok(("".to_string(), "abort".to_string()));
|
||||
}
|
||||
return self.compile_entity_node(
|
||||
schema,
|
||||
type_def,
|
||||
parent_alias,
|
||||
prop_name_context,
|
||||
false,
|
||||
filter_keys,
|
||||
is_stem_query,
|
||||
depth,
|
||||
);
|
||||
}
|
||||
|
||||
// Handle Direct Refs
|
||||
if let Some(ref_id) = &schema.obj.r#ref {
|
||||
// If it's just an ad-hoc struct ref, we should resolve it
|
||||
if let Some(target_schema) = self.db.schemas.get(ref_id) {
|
||||
return self.walk_schema(target_schema, parent_alias, prop_name_context, filter_keys);
|
||||
return self.walk_schema(
|
||||
target_schema,
|
||||
parent_alias,
|
||||
prop_name_context,
|
||||
filter_keys,
|
||||
is_stem_query,
|
||||
depth,
|
||||
);
|
||||
}
|
||||
return Err(format!("Unresolved $ref: {}", ref_id));
|
||||
}
|
||||
|
||||
// Just an inline object definition?
|
||||
if let Some(props) = &schema.obj.properties {
|
||||
return self.compile_inline_object(props, parent_alias, filter_keys);
|
||||
return self.compile_inline_object(
|
||||
props,
|
||||
parent_alias,
|
||||
filter_keys,
|
||||
is_stem_query,
|
||||
depth,
|
||||
);
|
||||
}
|
||||
|
||||
// Literal fallback
|
||||
@ -181,6 +170,27 @@ impl SqlCompiler {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_merged_properties(
|
||||
&self,
|
||||
schema: &crate::database::schema::Schema,
|
||||
) -> std::collections::BTreeMap<String, Arc<crate::database::schema::Schema>> {
|
||||
let mut props = std::collections::BTreeMap::new();
|
||||
|
||||
if let Some(ref_id) = &schema.obj.r#ref {
|
||||
if let Some(parent_schema) = self.db.schemas.get(ref_id) {
|
||||
props.extend(self.get_merged_properties(parent_schema));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(local_props) = &schema.obj.properties {
|
||||
for (k, v) in local_props {
|
||||
props.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
|
||||
props
|
||||
}
|
||||
|
||||
fn compile_entity_node(
|
||||
&self,
|
||||
schema: &crate::database::schema::Schema,
|
||||
@ -189,6 +199,8 @@ impl SqlCompiler {
|
||||
prop_name: Option<&str>,
|
||||
is_array: bool,
|
||||
filter_keys: &[String],
|
||||
is_stem_query: bool,
|
||||
depth: usize,
|
||||
) -> Result<(String, String), String> {
|
||||
// We are compiling a query block for an Entity.
|
||||
let mut select_args = Vec::new();
|
||||
@ -220,35 +232,45 @@ impl SqlCompiler {
|
||||
// grouped_fields is { "person": ["first_name", ...], "user": ["password"], ... }
|
||||
let grouped_fields = type_def.grouped_fields.as_ref().and_then(|v| v.as_object());
|
||||
|
||||
if let Some(props) = &schema.obj.properties {
|
||||
for (prop_key, prop_schema) in props {
|
||||
// Find which table owns this property
|
||||
// Find which table owns this property
|
||||
let mut owner_alias = table_aliases
|
||||
.get("entity")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("{}_t_err", parent_alias));
|
||||
let merged_props = self.get_merged_properties(schema);
|
||||
for (prop_key, prop_schema) in &merged_props {
|
||||
// Find which table owns this property
|
||||
// Find which table owns this property
|
||||
let mut owner_alias = table_aliases
|
||||
.get("entity")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| format!("{}_t_err", parent_alias));
|
||||
|
||||
if let Some(gf) = grouped_fields {
|
||||
for (t_name, fields_val) in gf {
|
||||
if let Some(fields_arr) = fields_val.as_array() {
|
||||
if fields_arr.iter().any(|v| v.as_str() == Some(prop_key)) {
|
||||
owner_alias = table_aliases
|
||||
.get(t_name)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| parent_alias.to_string());
|
||||
break;
|
||||
}
|
||||
if let Some(gf) = grouped_fields {
|
||||
for (t_name, fields_val) in gf {
|
||||
if let Some(fields_arr) = fields_val.as_array() {
|
||||
if fields_arr.iter().any(|v| v.as_str() == Some(prop_key)) {
|
||||
owner_alias = table_aliases
|
||||
.get(t_name)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| parent_alias.to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now we know `owner_alias`, e.g., `parent_t1` or `parent_t3`.
|
||||
// Walk the property to get its SQL value
|
||||
let (val_sql, _) =
|
||||
self.walk_schema(prop_schema, &owner_alias, Some(prop_key), filter_keys)?;
|
||||
select_args.push(format!("'{}', {}", prop_key, val_sql));
|
||||
}
|
||||
|
||||
// Now we know `owner_alias`, e.g., `parent_t1` or `parent_t3`.
|
||||
// Walk the property to get its SQL value
|
||||
let (val_sql, val_type) = self.walk_schema(
|
||||
prop_schema,
|
||||
&owner_alias,
|
||||
Some(prop_key),
|
||||
filter_keys,
|
||||
is_stem_query,
|
||||
depth + 1,
|
||||
)?;
|
||||
|
||||
if val_type == "abort" {
|
||||
continue;
|
||||
}
|
||||
|
||||
select_args.push(format!("'{}', {}", prop_key, val_sql));
|
||||
}
|
||||
|
||||
let jsonb_obj_sql = if select_args.is_empty() {
|
||||
@ -266,7 +288,7 @@ impl SqlCompiler {
|
||||
where_clauses.push(format!("NOT {}.archived", base_alias));
|
||||
// Filter Mapping - Only append filters if this is the ROOT table query (i.e. parent_alias is "t1")
|
||||
// Because cue.filters operates strictly on top-level root properties right now.
|
||||
if parent_alias == "t1" && prop_name.is_none() {
|
||||
if parent_alias == "t1" {
|
||||
for (i, filter_key) in filter_keys.iter().enumerate() {
|
||||
// Find which table owns this filter key
|
||||
let mut filter_alias = base_alias.clone(); // default to root table (e.g. t3 entity)
|
||||
@ -288,23 +310,36 @@ impl SqlCompiler {
|
||||
let mut is_ilike = false;
|
||||
let mut cast = "";
|
||||
|
||||
// Check schema for filter_key to determine datatype operation
|
||||
if let Some(props) = &schema.obj.properties {
|
||||
if let Some(ps) = props.get(filter_key) {
|
||||
let is_enum = ps.obj.enum_.is_some();
|
||||
if let Some(crate::database::schema::SchemaTypeOrArray::Single(t)) = &ps.obj.type_ {
|
||||
if t == "string" {
|
||||
if ps.obj.format.as_deref() == Some("uuid") {
|
||||
cast = "::uuid";
|
||||
} else if ps.obj.format.as_deref() == Some("date-time") {
|
||||
cast = "::timestamptz";
|
||||
} else if !is_enum {
|
||||
// Use PostgreSQL column type metadata for exact argument casting
|
||||
if let Some(field_types) = type_def.field_types.as_ref().and_then(|v| v.as_object()) {
|
||||
if let Some(pg_type_val) = field_types.get(filter_key) {
|
||||
if let Some(pg_type) = pg_type_val.as_str() {
|
||||
if pg_type == "uuid" {
|
||||
cast = "::uuid";
|
||||
} else if pg_type == "boolean" || pg_type == "bool" {
|
||||
cast = "::boolean";
|
||||
} else if pg_type.contains("timestamp")
|
||||
|| pg_type == "timestamptz"
|
||||
|| pg_type == "date"
|
||||
{
|
||||
cast = "::timestamptz";
|
||||
} else if pg_type == "numeric"
|
||||
|| pg_type.contains("int")
|
||||
|| pg_type == "real"
|
||||
|| pg_type == "double precision"
|
||||
{
|
||||
cast = "::numeric";
|
||||
} else if pg_type == "text" || pg_type.contains("char") {
|
||||
// Determine if this is an enum in the schema locally to avoid ILIKE on strict enums
|
||||
let mut is_enum = false;
|
||||
if let Some(props) = &schema.obj.properties {
|
||||
if let Some(ps) = props.get(filter_key) {
|
||||
is_enum = ps.obj.enum_.is_some();
|
||||
}
|
||||
}
|
||||
if !is_enum {
|
||||
is_ilike = true;
|
||||
}
|
||||
} else if t == "boolean" {
|
||||
cast = "::boolean";
|
||||
} else if t == "integer" || t == "number" {
|
||||
cast = "::numeric";
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -357,10 +392,22 @@ impl SqlCompiler {
|
||||
props: &std::collections::BTreeMap<String, std::sync::Arc<crate::database::schema::Schema>>,
|
||||
parent_alias: &str,
|
||||
filter_keys: &[String],
|
||||
is_stem_query: bool,
|
||||
depth: usize,
|
||||
) -> Result<(String, String), String> {
|
||||
let mut build_args = Vec::new();
|
||||
for (k, v) in props {
|
||||
let (child_sql, _) = self.walk_schema(v, parent_alias, Some(k), filter_keys)?;
|
||||
let (child_sql, val_type) = self.walk_schema(
|
||||
v,
|
||||
parent_alias,
|
||||
Some(k),
|
||||
filter_keys,
|
||||
is_stem_query,
|
||||
depth + 1,
|
||||
)?;
|
||||
if val_type == "abort" {
|
||||
continue;
|
||||
}
|
||||
build_args.push(format!("'{}', {}", k, child_sql));
|
||||
}
|
||||
let combined = format!("jsonb_build_object({})", build_args.join(", "));
|
||||
|
||||
@ -18,13 +18,12 @@ impl Queryer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Entrypoint to execute a dynamically compiled query based on a schema
|
||||
pub fn query(
|
||||
&self,
|
||||
schema_id: &str,
|
||||
stem_opt: Option<&str>,
|
||||
filters: Option<&serde_json::Value>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
) -> crate::drop::Drop {
|
||||
let filters_map: Option<&serde_json::Map<String, serde_json::Value>> =
|
||||
filters.and_then(|f| f.as_object());
|
||||
|
||||
@ -45,9 +44,21 @@ impl Queryer {
|
||||
} else {
|
||||
// Compile the massive base SQL string
|
||||
let compiler = compiler::SqlCompiler::new(self.db.clone());
|
||||
let compiled_sql = compiler.compile(schema_id, stem_opt, &filter_keys)?;
|
||||
self.cache.insert(cache_key.clone(), compiled_sql.clone());
|
||||
compiled_sql
|
||||
match compiler.compile(schema_id, stem_opt, &filter_keys) {
|
||||
Ok(compiled_sql) => {
|
||||
self.cache.insert(cache_key.clone(), compiled_sql.clone());
|
||||
compiled_sql
|
||||
}
|
||||
Err(e) => {
|
||||
return crate::drop::Drop::with_errors(vec![crate::drop::Error {
|
||||
code: "QUERY_COMPILATION_FAILED".to_string(),
|
||||
message: e,
|
||||
details: crate::drop::ErrorDetails {
|
||||
path: schema_id.to_string(),
|
||||
},
|
||||
}]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Prepare the execution arguments from the filters
|
||||
@ -62,22 +73,29 @@ impl Queryer {
|
||||
}
|
||||
|
||||
// 3. Execute via Database Executor
|
||||
let fetched = match self.db.query(&sql, Some(&args)) {
|
||||
match self.db.query(&sql, Some(&args)) {
|
||||
Ok(serde_json::Value::Array(table)) => {
|
||||
if table.is_empty() {
|
||||
Ok(serde_json::Value::Null)
|
||||
crate::drop::Drop::success_with_val(serde_json::Value::Null)
|
||||
} else {
|
||||
// We expect the query to return a single JSONB column, already unpacked from row[0]
|
||||
Ok(table.first().unwrap().clone())
|
||||
crate::drop::Drop::success_with_val(table.first().unwrap().clone())
|
||||
}
|
||||
}
|
||||
Ok(other) => Err(format!(
|
||||
"Expected array from generic query, got: {:?}",
|
||||
other
|
||||
)),
|
||||
Err(e) => Err(format!("SPI error in queryer: {}", e)),
|
||||
}?;
|
||||
|
||||
Ok(fetched)
|
||||
Ok(other) => crate::drop::Drop::with_errors(vec![crate::drop::Error {
|
||||
code: "QUERY_FAILED".to_string(),
|
||||
message: format!("Expected array from generic query, got: {:?}", other),
|
||||
details: crate::drop::ErrorDetails {
|
||||
path: schema_id.to_string(),
|
||||
},
|
||||
}]),
|
||||
Err(e) => crate::drop::Drop::with_errors(vec![crate::drop::Error {
|
||||
code: "QUERY_FAILED".to_string(),
|
||||
message: format!("SPI error in queryer: {}", e),
|
||||
details: crate::drop::ErrorDetails {
|
||||
path: schema_id.to_string(),
|
||||
},
|
||||
}]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
94
src/tests/mod.rs
Normal file
94
src/tests/mod.rs
Normal file
@ -0,0 +1,94 @@
|
||||
use crate::*;
|
||||
pub mod runner;
|
||||
pub mod types;
|
||||
use serde_json::json;
|
||||
|
||||
// Database module tests moved to src/database/executors/mock.rs
|
||||
|
||||
#[test]
|
||||
fn test_library_api() {
|
||||
// 1. Initially, schemas are not cached.
|
||||
|
||||
// Expected uninitialized drop format: errors + null response
|
||||
let uninitialized_drop = jspg_validate("test_schema", JsonB(json!({})));
|
||||
assert_eq!(
|
||||
uninitialized_drop.0,
|
||||
json!({
|
||||
"type": "drop",
|
||||
"errors": [{
|
||||
"code": "ENGINE_NOT_INITIALIZED",
|
||||
"message": "JSPG extension has not been initialized via jspg_setup",
|
||||
"details": { "path": "" }
|
||||
}]
|
||||
})
|
||||
);
|
||||
|
||||
// 2. Cache schemas
|
||||
let db_json = json!({
|
||||
"puncs": [],
|
||||
"enums": [],
|
||||
"relations": [],
|
||||
"types": [{
|
||||
"schemas": [{
|
||||
"$id": "test_schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" }
|
||||
},
|
||||
"required": ["name"]
|
||||
}]
|
||||
}]
|
||||
});
|
||||
|
||||
let cache_drop = jspg_setup(JsonB(db_json));
|
||||
assert_eq!(
|
||||
cache_drop.0,
|
||||
json!({
|
||||
"type": "drop",
|
||||
"response": "success"
|
||||
})
|
||||
);
|
||||
|
||||
// 4. Validate Happy Path
|
||||
let happy_drop = jspg_validate("test_schema", JsonB(json!({"name": "Neo"})));
|
||||
assert_eq!(
|
||||
happy_drop.0,
|
||||
json!({
|
||||
"type": "drop",
|
||||
"response": "success"
|
||||
})
|
||||
);
|
||||
|
||||
// 5. Validate Unhappy Path
|
||||
let unhappy_drop = jspg_validate("test_schema", JsonB(json!({"wrong": "data"})));
|
||||
assert_eq!(
|
||||
unhappy_drop.0,
|
||||
json!({
|
||||
"type": "drop",
|
||||
"errors": [
|
||||
{
|
||||
"code": "REQUIRED_FIELD_MISSING",
|
||||
"message": "Missing name",
|
||||
"details": { "path": "/name" }
|
||||
},
|
||||
{
|
||||
"code": "STRICT_PROPERTY_VIOLATION",
|
||||
"message": "Unexpected property 'wrong'",
|
||||
"details": { "path": "/wrong" }
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
// 6. Clear Schemas
|
||||
let clear_drop = jspg_teardown();
|
||||
assert_eq!(
|
||||
clear_drop.0,
|
||||
json!({
|
||||
"type": "drop",
|
||||
"response": "success"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
include!("fixtures.rs");
|
||||
113
src/tests/runner.rs
Normal file
113
src/tests/runner.rs
Normal file
@ -0,0 +1,113 @@
|
||||
use serde::Deserialize;
|
||||
use std::fs;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TestSuite {
|
||||
#[allow(dead_code)]
|
||||
description: String,
|
||||
database: serde_json::Value,
|
||||
tests: Vec<TestCase>,
|
||||
}
|
||||
|
||||
use crate::tests::types::{ExpectBlock, TestCase};
|
||||
|
||||
use crate::validator::Validator;
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn deserialize_some<'de, D>(deserializer: D) -> Result<Option<Value>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let v = Value::deserialize(deserializer)?;
|
||||
Ok(Some(v))
|
||||
}
|
||||
|
||||
pub fn run_test_file_at_index(path: &str, index: usize) -> Result<(), String> {
|
||||
let content =
|
||||
fs::read_to_string(path).unwrap_or_else(|_| panic!("Failed to read file: {}", path));
|
||||
let suite: Vec<TestSuite> = serde_json::from_str(&content)
|
||||
.unwrap_or_else(|e| panic!("Failed to parse JSON in {}: {}", path, e));
|
||||
|
||||
if index >= suite.len() {
|
||||
panic!("Index {} out of bounds for file {}", index, path);
|
||||
}
|
||||
|
||||
let group = &suite[index];
|
||||
let mut failures = Vec::<String>::new();
|
||||
|
||||
let db_json = group.database.clone();
|
||||
let db_result = crate::database::Database::new(&db_json);
|
||||
if let Err(drop) = db_result {
|
||||
let error_messages: Vec<String> = drop
|
||||
.errors
|
||||
.into_iter()
|
||||
.map(|e| format!("Error {} at path {}: {}", e.code, e.details.path, e.message))
|
||||
.collect();
|
||||
return Err(format!(
|
||||
"System Setup Compilation failed:\n{}",
|
||||
error_messages.join("\n")
|
||||
));
|
||||
}
|
||||
let db = db_result.unwrap();
|
||||
let validator = Validator::new(std::sync::Arc::new(db));
|
||||
|
||||
// 4. Run Tests
|
||||
for test in group.tests.iter() {
|
||||
// Provide fallback for legacy expectations if `expect` block was missing despite migration script
|
||||
let expected_success = test
|
||||
.expect
|
||||
.as_ref()
|
||||
.map(|e| e.success)
|
||||
.unwrap_or(test.valid.unwrap_or(false));
|
||||
let _expected_errors = test
|
||||
.expect
|
||||
.as_ref()
|
||||
.and_then(|e| e.errors.clone())
|
||||
.unwrap_or(test.expect_errors.clone().unwrap_or(vec![]));
|
||||
|
||||
match test.action.as_str() {
|
||||
"validate" => {
|
||||
let result = test.run_validate(validator.db.clone());
|
||||
if let Err(e) = result {
|
||||
println!("TEST VALIDATE ERROR FOR '{}': {}", test.description, e);
|
||||
failures.push(format!(
|
||||
"[{}] Validate Test '{}' failed. Error: {}",
|
||||
group.description, test.description, e
|
||||
));
|
||||
}
|
||||
}
|
||||
"merge" => {
|
||||
let result = test.run_merge(validator.db.clone());
|
||||
if let Err(e) = result {
|
||||
println!("TEST MERGE ERROR FOR '{}': {}", test.description, e);
|
||||
failures.push(format!(
|
||||
"[{}] Merge Test '{}' failed. Error: {}",
|
||||
group.description, test.description, e
|
||||
));
|
||||
}
|
||||
}
|
||||
"query" => {
|
||||
let result = test.run_query(validator.db.clone());
|
||||
if let Err(e) = result {
|
||||
println!("TEST QUERY ERROR FOR '{}': {}", test.description, e);
|
||||
failures.push(format!(
|
||||
"[{}] Query Test '{}' failed. Error: {}",
|
||||
group.description, test.description, e
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
failures.push(format!(
|
||||
"[{}] Unknown action '{}' for test '{}'",
|
||||
group.description, test.action, test.description
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !failures.is_empty() {
|
||||
return Err(failures.join("\n"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
167
src/tests/types/case.rs
Normal file
167
src/tests/types/case.rs
Normal file
@ -0,0 +1,167 @@
|
||||
use super::expect::ExpectBlock;
|
||||
use crate::database::Database;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TestCase {
|
||||
pub description: String,
|
||||
|
||||
#[serde(default = "default_action")]
|
||||
pub action: String, // "validate", "merge", or "query"
|
||||
|
||||
// For Validate & Query
|
||||
#[serde(default)]
|
||||
pub schema_id: String,
|
||||
|
||||
// For Query
|
||||
#[serde(default)]
|
||||
pub stem: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub filters: Option<serde_json::Value>,
|
||||
|
||||
// For Merge & Validate
|
||||
#[serde(default)]
|
||||
pub data: Option<serde_json::Value>,
|
||||
|
||||
// For Merge & Query mocks
|
||||
#[serde(default)]
|
||||
pub mocks: Option<serde_json::Value>,
|
||||
|
||||
pub expect: Option<ExpectBlock>,
|
||||
|
||||
// Legacy support for older tests to avoid migrating them all instantly
|
||||
pub valid: Option<bool>,
|
||||
pub expect_errors: Option<Vec<serde_json::Value>>,
|
||||
}
|
||||
|
||||
fn default_action() -> String {
|
||||
"validate".to_string()
|
||||
}
|
||||
|
||||
impl TestCase {
|
||||
pub fn execute(&self, db: Arc<Database>) -> Result<(), String> {
|
||||
match self.action.as_str() {
|
||||
"validate" => self.run_validate(db),
|
||||
"merge" => self.run_merge(db),
|
||||
"query" => self.run_query(db),
|
||||
_ => Err(format!(
|
||||
"Unknown action '{}' for test '{}'",
|
||||
self.action, self.description
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_validate(&self, db: Arc<Database>) -> Result<(), String> {
|
||||
use crate::validator::Validator;
|
||||
|
||||
let validator = Validator::new(db);
|
||||
|
||||
let expected_success = self
|
||||
.expect
|
||||
.as_ref()
|
||||
.map(|e| e.success)
|
||||
.unwrap_or(self.valid.unwrap_or(false));
|
||||
|
||||
// _expected_errors is preserved for future diffing if needed
|
||||
let _expected_errors = self
|
||||
.expect
|
||||
.as_ref()
|
||||
.and_then(|e| e.errors.clone())
|
||||
.unwrap_or(self.expect_errors.clone().unwrap_or(vec![]));
|
||||
|
||||
let schema_id = &self.schema_id;
|
||||
if !validator.db.schemas.contains_key(schema_id) {
|
||||
return Err(format!(
|
||||
"Missing Schema: Cannot find schema ID '{}'",
|
||||
schema_id
|
||||
));
|
||||
}
|
||||
|
||||
let test_data = self.data.clone().unwrap_or(Value::Null);
|
||||
let result = validator.validate(schema_id, &test_data);
|
||||
|
||||
let got_valid = result.errors.is_empty();
|
||||
|
||||
if got_valid != expected_success {
|
||||
let error_msg = if result.errors.is_empty() {
|
||||
"None".to_string()
|
||||
} else {
|
||||
format!("{:?}", result.errors)
|
||||
};
|
||||
|
||||
return Err(format!(
|
||||
"Expected: {}, Got: {}. Errors: {}",
|
||||
expected_success, got_valid, error_msg
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn run_merge(&self, db: Arc<Database>) -> Result<(), String> {
|
||||
use crate::merger::Merger;
|
||||
let merger = Merger::new(db.clone());
|
||||
|
||||
let test_data = self.data.clone().unwrap_or(Value::Null);
|
||||
let result = merger.merge(test_data);
|
||||
|
||||
let expected_success = self.expect.as_ref().map(|e| e.success).unwrap_or(false);
|
||||
let got_success = result.errors.is_empty();
|
||||
|
||||
let error_msg = if result.errors.is_empty() {
|
||||
"None".to_string()
|
||||
} else {
|
||||
format!("{:?}", result.errors)
|
||||
};
|
||||
|
||||
let return_val = if expected_success != got_success {
|
||||
Err(format!(
|
||||
"Merge Expected: {}, Got: {}. Errors: {}",
|
||||
expected_success, got_success, error_msg
|
||||
))
|
||||
} else if let Some(expect) = &self.expect {
|
||||
let queries = db.executor.get_queries();
|
||||
expect.assert_sql(&queries)
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
|
||||
db.executor.reset_mocks();
|
||||
return_val
|
||||
}
|
||||
|
||||
pub fn run_query(&self, db: Arc<Database>) -> Result<(), String> {
|
||||
use crate::queryer::Queryer;
|
||||
let queryer = Queryer::new(db.clone());
|
||||
|
||||
let stem_opt = self.stem.as_deref();
|
||||
let result = queryer.query(&self.schema_id, stem_opt, self.filters.as_ref());
|
||||
|
||||
let expected_success = self.expect.as_ref().map(|e| e.success).unwrap_or(false);
|
||||
let got_success = result.errors.is_empty();
|
||||
|
||||
let error_msg = if result.errors.is_empty() {
|
||||
"None".to_string()
|
||||
} else {
|
||||
format!("{:?}", result.errors)
|
||||
};
|
||||
|
||||
let return_val = if expected_success != got_success {
|
||||
Err(format!(
|
||||
"Query Expected: {}, Got: {}. Errors: {}",
|
||||
expected_success, got_success, error_msg
|
||||
))
|
||||
} else if let Some(expect) = &self.expect {
|
||||
let queries = db.executor.get_queries();
|
||||
expect.assert_sql(&queries)
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
|
||||
db.executor.reset_mocks();
|
||||
return_val
|
||||
}
|
||||
}
|
||||
122
src/tests/types/expect.rs
Normal file
122
src/tests/types/expect.rs
Normal file
@ -0,0 +1,122 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ExpectBlock {
|
||||
pub success: bool,
|
||||
pub result: Option<serde_json::Value>,
|
||||
pub errors: Option<Vec<serde_json::Value>>,
|
||||
#[serde(default)]
|
||||
pub sql: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl ExpectBlock {
|
||||
/// Advanced SQL execution assertion algorithm ported from `assert.go`.
|
||||
/// This compares two arrays of strings, one containing {{uuid:name}} or {{timestamp}} placeholders,
|
||||
/// and the other containing actual executed database queries. It ensures that placeholder UUIDs
|
||||
/// are consistently mapped to the same actual UUIDs across all lines, and strictly validates line-by-line sequences.
|
||||
pub fn assert_sql(&self, actual: &[String]) -> Result<(), String> {
|
||||
let patterns = match &self.sql {
|
||||
Some(s) => s,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
if patterns.len() != actual.len() {
|
||||
return Err(format!(
|
||||
"Length mismatch: expected {} SQL executions, got {}.\nActual Execution Log:\n{}",
|
||||
patterns.len(),
|
||||
actual.len(),
|
||||
actual.join("\n")
|
||||
));
|
||||
}
|
||||
|
||||
use regex::Regex;
|
||||
use std::collections::HashMap;
|
||||
|
||||
let types = HashMap::from([
|
||||
(
|
||||
"uuid",
|
||||
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",
|
||||
),
|
||||
(
|
||||
"timestamp",
|
||||
r"\d{4}-\d{2}-\d{2}(?:[ T])\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|\+\d{2}(?::\d{2})?)?",
|
||||
),
|
||||
("integer", r"-?\d+"),
|
||||
("float", r"-?\d+\.\d+"),
|
||||
("text", r"(?:''|[^'])*"),
|
||||
("json", r"(?:''|[^'])*"),
|
||||
]);
|
||||
|
||||
let mut seen: HashMap<String, String> = HashMap::new();
|
||||
let system_uuid = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
// Placeholder regex: {{type:name}} or {{type}}
|
||||
let ph_rx = Regex::new(r"\{\{([a-z]+)(?:[:]([^}]+))?\}\}").unwrap();
|
||||
|
||||
for (i, pattern_str) in patterns.iter().enumerate() {
|
||||
let aline = &actual[i];
|
||||
let mut pp = regex::escape(pattern_str);
|
||||
pp = pp.replace(r"\{\{", "{{").replace(r"\}\}", "}}");
|
||||
|
||||
let mut cap_names = HashMap::new(); // cg_X -> var_name
|
||||
let mut group_idx = 0;
|
||||
|
||||
let mut final_rx_str = String::new();
|
||||
let mut last_match = 0;
|
||||
|
||||
let pp_clone = pp.clone();
|
||||
for caps in ph_rx.captures_iter(&pp_clone) {
|
||||
let full_match = caps.get(0).unwrap();
|
||||
final_rx_str.push_str(&pp[last_match..full_match.start()]);
|
||||
|
||||
let type_name = caps.get(1).unwrap().as_str();
|
||||
let var_name = caps.get(2).map(|m| m.as_str());
|
||||
|
||||
if let Some(name) = var_name {
|
||||
if let Some(val) = seen.get(name) {
|
||||
final_rx_str.push_str(®ex::escape(val));
|
||||
} else {
|
||||
let type_pattern = types.get(type_name).unwrap_or(&".*?");
|
||||
let cg_name = format!("cg_{}", group_idx);
|
||||
final_rx_str.push_str(&format!("(?P<{}>{})", cg_name, type_pattern));
|
||||
cap_names.insert(cg_name, name.to_string());
|
||||
group_idx += 1;
|
||||
}
|
||||
} else {
|
||||
let type_pattern = types.get(type_name).unwrap_or(&".*?");
|
||||
final_rx_str.push_str(&format!("(?:{})", type_pattern));
|
||||
}
|
||||
|
||||
last_match = full_match.end();
|
||||
}
|
||||
final_rx_str.push_str(&pp[last_match..]);
|
||||
|
||||
let final_rx = match Regex::new(&format!("^{}$", final_rx_str)) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Err(format!("Bad constructed regex: {} -> {}", final_rx_str, e)),
|
||||
};
|
||||
|
||||
if let Some(captures) = final_rx.captures(aline) {
|
||||
for (cg_name, var_name) in cap_names {
|
||||
if let Some(m) = captures.name(&cg_name) {
|
||||
let matched_str = m.as_str();
|
||||
if matched_str != system_uuid {
|
||||
seen.insert(var_name, matched_str.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Line mismatched at execution sequence {}.\nExpected Pattern: {}\nActual SQL: {}\nRegex used: {}\nVariables Mapped: {:?}",
|
||||
i + 1,
|
||||
pattern_str,
|
||||
aline,
|
||||
final_rx_str,
|
||||
seen
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
7
src/tests/types/mod.rs
Normal file
7
src/tests/types/mod.rs
Normal file
@ -0,0 +1,7 @@
|
||||
pub mod case;
|
||||
pub mod expect;
|
||||
pub mod suite;
|
||||
|
||||
pub use case::TestCase;
|
||||
pub use expect::ExpectBlock;
|
||||
pub use suite::TestSuite;
|
||||
10
src/tests/types/suite.rs
Normal file
10
src/tests/types/suite.rs
Normal file
@ -0,0 +1,10 @@
|
||||
use super::case::TestCase;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TestSuite {
|
||||
#[allow(dead_code)]
|
||||
pub description: String,
|
||||
pub database: serde_json::Value,
|
||||
pub tests: Vec<TestCase>,
|
||||
}
|
||||
@ -4,7 +4,6 @@ pub mod context;
|
||||
pub mod error;
|
||||
pub mod result;
|
||||
pub mod rules;
|
||||
pub mod util;
|
||||
|
||||
pub use context::ValidationContext;
|
||||
pub use error::ValidationError;
|
||||
@ -46,11 +45,7 @@ impl Validator {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate(
|
||||
&self,
|
||||
schema_id: &str,
|
||||
instance: &Value,
|
||||
) -> Result<ValidationResult, ValidationError> {
|
||||
pub fn validate(&self, schema_id: &str, instance: &Value) -> crate::drop::Drop {
|
||||
if let Some(schema) = self.db.schemas.get(schema_id) {
|
||||
let ctx = ValidationContext::new(
|
||||
&self.db,
|
||||
@ -61,13 +56,37 @@ impl Validator {
|
||||
false,
|
||||
false,
|
||||
);
|
||||
ctx.validate_scoped()
|
||||
match ctx.validate_scoped() {
|
||||
Ok(result) => {
|
||||
if result.is_valid() {
|
||||
crate::drop::Drop::success()
|
||||
} else {
|
||||
let errors: Vec<crate::drop::Error> = result
|
||||
.errors
|
||||
.into_iter()
|
||||
.map(|e| crate::drop::Error {
|
||||
code: e.code,
|
||||
message: e.message,
|
||||
details: crate::drop::ErrorDetails { path: e.path },
|
||||
})
|
||||
.collect();
|
||||
crate::drop::Drop::with_errors(errors)
|
||||
}
|
||||
}
|
||||
Err(e) => crate::drop::Drop::with_errors(vec![crate::drop::Error {
|
||||
code: e.code,
|
||||
message: e.message,
|
||||
details: crate::drop::ErrorDetails { path: e.path },
|
||||
}]),
|
||||
}
|
||||
} else {
|
||||
Err(ValidationError {
|
||||
crate::drop::Drop::with_errors(vec![crate::drop::Error {
|
||||
code: "SCHEMA_NOT_FOUND".to_string(),
|
||||
message: format!("Schema {} not found", schema_id),
|
||||
path: "".to_string(),
|
||||
})
|
||||
details: crate::drop::ErrorDetails {
|
||||
path: "".to_string(),
|
||||
},
|
||||
}])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,127 +0,0 @@
|
||||
use serde::Deserialize;
|
||||
use std::fs;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TestSuite {
|
||||
#[allow(dead_code)]
|
||||
description: String,
|
||||
database: serde_json::Value,
|
||||
tests: Vec<TestCase>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TestCase {
|
||||
pub description: String,
|
||||
|
||||
#[serde(default = "default_action")]
|
||||
pub action: String, // "validate", "merge", or "query"
|
||||
|
||||
// For Validate & Query
|
||||
#[serde(default)]
|
||||
pub schema_id: String,
|
||||
|
||||
// For Query
|
||||
#[serde(default)]
|
||||
pub stem: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub filters: Option<serde_json::Value>,
|
||||
|
||||
// For Merge & Validate
|
||||
#[serde(default)]
|
||||
pub data: Option<serde_json::Value>,
|
||||
|
||||
// For Merge & Query mocks
|
||||
#[serde(default)]
|
||||
pub mocks: Option<serde_json::Value>,
|
||||
|
||||
pub expect: Option<ExpectBlock>,
|
||||
|
||||
// Legacy support for older tests to avoid migrating them all instantly
|
||||
pub valid: Option<bool>,
|
||||
pub expect_errors: Option<Vec<serde_json::Value>>,
|
||||
}
|
||||
|
||||
fn default_action() -> String {
|
||||
"validate".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ExpectBlock {
|
||||
pub success: bool,
|
||||
pub result: Option<serde_json::Value>,
|
||||
pub errors: Option<Vec<serde_json::Value>>,
|
||||
pub sql_patterns: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
// use crate::validator::registry::REGISTRY; // No longer used directly for tests!
|
||||
use crate::validator::Validator;
|
||||
use serde_json::Value;
|
||||
|
||||
pub fn deserialize_some<'de, D>(deserializer: D) -> Result<Option<Value>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let v = Value::deserialize(deserializer)?;
|
||||
Ok(Some(v))
|
||||
}
|
||||
|
||||
pub fn run_test_file_at_index(path: &str, index: usize) -> Result<(), String> {
|
||||
let content =
|
||||
fs::read_to_string(path).unwrap_or_else(|_| panic!("Failed to read file: {}", path));
|
||||
let suite: Vec<TestSuite> = serde_json::from_str(&content)
|
||||
.unwrap_or_else(|e| panic!("Failed to parse JSON in {}: {}", path, e));
|
||||
|
||||
if index >= suite.len() {
|
||||
panic!("Index {} out of bounds for file {}", index, path);
|
||||
}
|
||||
|
||||
let group = &suite[index];
|
||||
let mut failures = Vec::<String>::new();
|
||||
|
||||
let db_json = group.database.clone();
|
||||
let db = crate::database::Database::new(&db_json);
|
||||
let validator = Validator::new(std::sync::Arc::new(db));
|
||||
|
||||
// 4. Run Tests
|
||||
for test in group.tests.iter() {
|
||||
let schema_id = &test.schema_id;
|
||||
|
||||
if !validator.db.schemas.contains_key(schema_id) {
|
||||
failures.push(format!(
|
||||
"[{}] Missing Schema: Cannot find schema ID '{}'",
|
||||
group.description, schema_id
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
let result = validator.validate(schema_id, &test.data);
|
||||
|
||||
let (got_valid, _errors) = match &result {
|
||||
Ok(res) => (res.is_valid(), &res.errors),
|
||||
Err(_e) => {
|
||||
// If we encounter an execution error (e.g. Schema Not Found),
|
||||
// we treat it as a test failure.
|
||||
(false, &vec![])
|
||||
}
|
||||
};
|
||||
|
||||
if got_valid != test.valid {
|
||||
let error_msg = match &result {
|
||||
Ok(res) => format!("{:?}", res.errors),
|
||||
Err(e) => format!("Execution Error: {:?}", e),
|
||||
};
|
||||
|
||||
failures.push(format!(
|
||||
"[{}] Test '{}' failed. Expected: {}, Got: {}. Errors: {}",
|
||||
group.description, test.description, test.valid, got_valid, error_msg
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if !failures.is_empty() {
|
||||
return Err(failures.join("\n"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user