Compare commits

...

25 Commits

Author SHA1 Message Date
4a10833f50 version: 1.0.94 2026-03-26 23:50:03 -04:00
46fc032026 fixed merge lookup issue 2026-03-26 23:49:52 -04:00
7ec06b81cc version: 1.0.93 2026-03-26 22:28:18 -04:00
c4e8e0309f removed initial / in validator making paths consistent across validate merger and queryer 2026-03-26 22:27:59 -04:00
eb91b65e65 version: 1.0.92 2026-03-26 14:06:40 -04:00
8bf3649465 validator now uses hybrid uuid and numeric index pathing 2026-03-26 14:06:24 -04:00
9fe5a34163 version: 1.0.91 2026-03-25 21:37:15 -04:00
f5bf21eb58 fixed root array queries 2026-03-25 21:37:01 -04:00
9dcafed406 version: 1.0.90 2026-03-25 19:32:02 -04:00
ffd6c27da3 more pg try catching and error handling 2026-03-25 19:31:51 -04:00
4941dc6069 doc update 2026-03-23 19:07:45 -04:00
a8a15a82ef version: 1.0.89 2026-03-23 16:41:41 -04:00
8dcc714963 fixed target_type restrictions in where clauses 2026-03-23 16:41:34 -04:00
f87ac81f3b pre-script-fix 2026-03-23 16:34:45 -04:00
8ca9017cc4 version: 1.0.88 2026-03-23 14:37:29 -04:00
10c57e59ec fixed nested filtering syntax 2026-03-23 14:37:22 -04:00
ef4571767c version: 1.0.87 2026-03-23 12:49:36 -04:00
29bd25eaff fixed filter override for archived 2026-03-23 12:49:30 -04:00
4d9b510819 version: 1.0.86 2026-03-23 12:26:03 -04:00
3c4b1066df fixed merger with anchor test issue 2026-03-23 12:25:55 -04:00
4c59d9ba7f version: 1.0.85 2026-03-23 12:05:47 -04:00
a1038490dd tested nested merging with anchors 2026-03-23 12:05:34 -04:00
14707330a7 subschema id queryer test added 2026-03-22 05:54:31 -04:00
77bc92533c version: 1.0.84 2026-03-22 03:35:54 -04:00
4060119b01 schema ids can now contain a subschema 2026-03-22 03:35:47 -04:00
22 changed files with 1327 additions and 1163 deletions

View File

@ -7,22 +7,29 @@
JSPG operates by deeply integrating the JSON Schema Draft 2020-12 specification directly into the Postgres session lifecycle. It is built around three core pillars:
* **Validator**: In-memory, near-instant JSON structural validation and type polymorphism routing.
* **Merger**: Automatically traverse and UPSERT deeply nested JSON graphs into normalized relational tables.
* **Queryer**: Compile JSON Schemas into static, cached SQL SPI `SELECT` plans for fetching full entities or isolated "Stems".
* **Queryer**: Compile JSON Schemas into static, cached SQL SPI `SELECT` plans for fetching full entities or isolated ad-hoc object boundaries.
### 🎯 Goals
1. **Draft 2020-12 Compliance**: Attempt to adhere to the official JSON Schema Draft 2020-12 specification.
2. **Ultra-Fast Execution**: Compile schemas into optimized in-memory validation trees and cached SQL SPIs to bypass Postgres Query Builder overheads.
3. **Connection-Bound Caching**: Leverage the PostgreSQL session lifecycle using an **Atomic Swap** pattern. Schemas are 100% frozen, completely eliminating locks during read access.
4. **Structural Inheritance**: Support object-oriented schema design via Implicit Keyword Shadowing and virtual `$family` references natively mapped to Postgres table constraints.
5. **Reactive Beats**: Provide natively generated "Stems" (isolated payload fragments) for dynamic websocket reactivity.
5. **Reactive Beats**: Provide ultra-fast natively generated flat payloads mapping directly to the Dart topological state for dynamic websocket reactivity.
### Concurrency & Threading ("Immutable Graphs")
To support high-throughput operations while allowing for runtime updates (e.g., during hot-reloading), JSPG uses an **Atomic Swap** pattern:
1. **Parser Phase**: Schema JSONs are parsed into ordered `Schema` structs.
2. **Compiler Phase**: The database iterates all parsed schemas and pre-computes native optimization maps (Descendants Map, Depths Map, Variations Map).
3. **Immutable Validator**: The `Validator` struct immutably owns the `Database` registry and all its global maps. Schemas themselves are completely frozen; `$ref` strings are resolved dynamically at runtime using pre-computed O(1) maps.
3. **Immutable AST Caching**: The `Validator` struct immutably owns the `Database` registry. Schemas themselves are frozen structurally, but utilize `OnceLock` interior mutability during the Compilation Phase to permanently cache resolved `$ref` inheritances, properties, and `compiled_edges` directly onto their AST nodes. This guarantees strict `O(1)` relationship and property validation execution at runtime without locking or recursive DB polling.
4. **Lock-Free Reads**: Incoming operations acquire a read lock just long enough to clone the `Arc` inside an `RwLock<Option<Arc<Validator>>>`, ensuring zero blocking during schema updates.
### Global API Reference
These functions operate on the global `GLOBAL_JSPG` engine instance and provide administrative boundaries:
* `jspg_setup(database jsonb) -> jsonb`: Initializes the engine. Deserializes the full database schema registry (types, enums, puncs, relations) from Postgres and compiles them into memory atomically.
* `jspg_teardown() -> jsonb`: Clears the current session's engine instance from `GLOBAL_JSPG`, resetting the cache.
* `jspg_schemas() -> jsonb`: Exports the fully compiled AST snapshot (including all inherited dependencies) out of `GLOBAL_JSPG` into standard JSON Schema representations.
---
## 2. Validator
@ -30,10 +37,7 @@ To support high-throughput operations while allowing for runtime updates (e.g.,
The Validator provides strict, schema-driven evaluation for the "Punc" architecture.
### API Reference
* `jspg_setup(database jsonb) -> jsonb`: Loads and compiles the entire registry (types, enums, puncs, relations) atomically.
* `mask_json_schema(schema_id text, instance jsonb) -> jsonb`: Validates and prunes unknown properties dynamically, returning masked data.
* `jspg_validate(schema_id text, instance jsonb) -> jsonb`: Returns boolean-like success or structured errors.
* `jspg_teardown() -> jsonb`: Clears the current session's schema cache.
* `jspg_validate(schema_id text, instance jsonb) -> jsonb`: Validates the `instance` JSON payload strictly against the constraints of the registered `schema_id`. Returns boolean-like success or structured error codes.
### Custom Features & Deviations
JSPG implements specific extensions to the Draft 2020-12 standard to support the Punc architecture's object-oriented needs while heavily optimizing for zero-runtime lookups.
@ -69,11 +73,14 @@ To simplify frontend form validation, format validators specifically for `uuid`,
## 3. Merger
The Merger provides an automated, high-performance graph synchronization engine via the `jspg_merge(cue JSONB)` API. It orchestrates the complex mapping of nested JSON objects into normalized Postgres relational tables, honoring all inheritance and graph constraints.
The Merger provides an automated, high-performance graph synchronization engine. It orchestrates the complex mapping of nested JSON objects into normalized Postgres relational tables, honoring all inheritance and graph constraints.
### API Reference
* `jspg_merge(schema_id text, data jsonb) -> jsonb`: Traverses the provided JSON payload according to the compiled relational map of `schema_id`. Dynamically builds and executes relational SQL UPSERT paths natively.
### Core Features
* **Caching Strategy**: The Merger leverages the `Validator`'s in-memory `Database` registry to instantly resolve Foreign Key mapping graphs. It additionally utilizes the concurrent `GLOBAL_JSPG` application memory (`DashMap`) to cache statically constructed SQL `SELECT` strings used during deduplication (`lk_`) and difference tracking calculations.
* **Caching Strategy**: The Merger leverages the native `compiled_edges` permanently cached onto the Schema AST via `OnceLock` to instantly resolve Foreign Key mapping graphs natively in absolute `O(1)` time. It additionally utilizes the concurrent `GLOBAL_JSPG` application memory (`DashMap`) to cache statically constructed SQL `SELECT` strings used during deduplication (`lk_`) and difference tracking calculations.
* **Deep Graph Merging**: The Merger walks arbitrary levels of deeply nested JSON schemas (e.g. tracking an `order`, its `customer`, and an array of its `lines`). It intelligently discovers the correct parent-to-child or child-to-parent Foreign Keys stored in the registry and automatically maps the UUIDs across the relationships during UPSERT.
* **Prefix Foreign Key Matching**: Handles scenario where multiple relations point to the same table by using database Foreign Key constraint prefixes (`fk_`). For example, if a schema has `shipping_address` and `billing_address`, the merger resolves against `fk_shipping_address_entity` vs `fk_billing_address_entity` automatically to correctly route object properties.
* **Dynamic Deduplication & Lookups**: If a nested object is provided without an `id`, the Merger utilizes Postgres `lk_` index constraints defined in the schema registry (e.g. `lk_person` mapped to `first_name` and `last_name`). It dynamically queries these unique matching constraints to discover the correct UUID to perform an UPDATE, preventing data duplication.
@ -91,7 +98,10 @@ The Merger provides an automated, high-performance graph synchronization engine
## 4. Queryer
The Queryer transforms Postgres into a pre-compiled Semantic Query Engine via the `jspg_query(schema_id text, cue jsonb)` API, designed to serve the exact shape of Punc responses directly via SQL.
The Queryer transforms Postgres into a pre-compiled Semantic Query Engine, designed to serve the exact shape of Punc responses directly via SQL.
### API Reference
* `jspg_query(schema_id text, filters jsonb) -> jsonb`: Compiles the JSON Schema AST of `schema_id` directly into pre-planned, nested multi-JOIN SQL execution trees. Processes `filters` structurally.
### Core Features
@ -108,22 +118,11 @@ The Queryer transforms Postgres into a pre-compiled Semantic Query Engine via th
* **Multi-Table Branching**: If the Physical Table is a parent to other tables (e.g. `organization` has variations `["organization", "bot", "person"]`), the compiler generates a dynamic `CASE WHEN type = '...' THEN ...` query, expanding into `JOIN`s for each variation.
* **Single-Table Bypass**: If the Physical Table is a leaf node with only one variation (e.g. `person` has variations `["person"]`), the compiler cleanly bypasses `CASE` generation and compiles a simple `SELECT` across the base table, as all schema extensions (e.g. `light.person`, `full.person`) are guaranteed to reside in the exact same physical row.
### The Stem Engine
### Ad-Hoc Schema Promotion
Rather than over-fetching heavy Entity payloads and trimming them, Punc Framework Websockets depend on isolated subgraphs defined as **Stems**.
A `Stem` is a declaration of an **Entity Type boundary** that exists somewhere within the compiled JSON Schema graph, expressed using **`gjson` multipath syntax** (e.g., `contacts.#.phone_numbers.#`).
Because `pg_notify` (Beats) fire rigidly from physical Postgres tables (e.g. `{"type": "phone_number"}`), the Go Framework only ever needs to know: "Does the schema `with_contacts.person` contain the `phone_number` Entity anywhere inside its tree, and if so, what is the gjson path to iterate its payload?"
* **Initialization:** During startup (`jspg_stems()`), the database crawls all Schemas and maps out every physical Entity Type it references. It builds a highly optimized `HashMap<String, HashMap<String, Arc<Stem>>>` providing strictly `O(1)` memory lookups mapping `Schema ID -> { Stem Path -> Entity Type }`.
* **GJSON Pathing:** Unlike standard JSON Pointers, stems utilize `.#` array iterator syntax. The Go web server consumes this native path (e.g. `lines.#`) across the raw Postgres JSON byte payload, extracting all active UUIDs in one massive sub-millisecond sweep without unmarshaling Go ASTs.
* **Polymorphic Condition Selectors:** When trailing paths would otherwise collide because of abstract polymorphic type definitions (e.g., a `target` property bounded by a `oneOf` taking either `phone_number` or `email_address`), JSPG natively appends evaluated `gjson` type conditions into the path (e.g. `contacts.#.target#(type=="phone_number")`). This guarantees `O(1)` key uniqueness in the HashMap while retaining extreme array extraction speeds natively without runtime AST evaluation.
* **Identifier Prioritization:** When determining if a nested object boundary is an Entity, JSPG natively prioritizes defined `$id` tags over `$ref` inheritance pointers to prevent polymorphic boundaries from devolving into their generic base classes.
* **Cyclical Deduplication:** Because Punc relationships often reference back on themselves via deeply nested classes, the Stem Engine applies intelligent path deduplication. If the active `current_path` already ends with the target entity string, it traverses the inheritance properties without appending the entity to the stem path again, eliminating infinite powerset loops.
* **Relationship Path Squashing:** When calculating string paths structurally, JSPG intentionally **omits** properties natively named `target` or `source` if they belong to a native database `relationship` table override.
* **The Go Router**: The Golang Punc framework uses this exact mapping to register WebSocket Beat frequencies exclusively on the Entity types discovered.
* **The Queryer Execution**: When the Go framework asks JSPG to hydrate a partial `phone_number` stem for the `with_contacts.person` schema, instead of jumping through string paths, the SQL Compiler simply reaches into the Schema's AST using the `phone_number` Type string, pulls out exactly that entity's mapping rules, and returns a fully correlated `SELECT` block! This natively handles nested array properties injected via `oneOf` or array references efficiently bypassing runtime powerset expansion.
* **Performance:** These Stem execution structures are fully statically compiled via SPI and map perfectly to `O(1)` real-time routing logic on the application tier.
To seamlessly support deeply nested, inline Object definitions that don't declare an explicit `$id`, JSPG aggressively promotes them to standalone topological entities during the database compilation phase.
* **Hash Generation:** While evaluating the unified graph, if the compiler enters an `Object` or `Array` structure completely lacking an `$id`, it dynamically calculates a localized hash alias representing exactly its structural constraints.
* **Promotion:** This inline chunk is mathematically elevated to its own `$id` in the `db.schemas` cache registry. This guarantees that $O(1)$ WebSockets or isolated queries can natively target any arbitrary sub-object of a massive database topology directly without recursively re-parsing its parent's AST block every read.
## 5. Testing & Execution Architecture

819
d1.json
View File

@ -1,819 +0,0 @@
{
"database": {
"puncs": [],
"enums": [
{
"id": "11111111-1111-1111-1111-111111111111",
"type": "relation_type",
"enum": "relation_type",
"values": [
"foreign_key",
"polymorphic",
"graph"
]
}
],
"relations": [
{
"id": "22222222-2222-2222-2222-222222222222",
"type": "relation",
"constraint": "fk_order_customer",
"source_type": "order",
"source_columns": [
"customer_id"
],
"destination_type": "person",
"destination_columns": [
"id"
],
"prefix": "customer"
},
{
"id": "33333333-3333-3333-3333-333333333333",
"type": "relation",
"constraint": "fk_order_line_order",
"source_type": "order_line",
"source_columns": [
"order_id"
],
"destination_type": "order",
"destination_columns": [
"id"
],
"prefix": "lines"
},
{
"id": "44444444-4444-4444-4444-444444444444",
"type": "relation",
"constraint": "fk_relationship_source_entity",
"source_type": "relationship",
"source_columns": [
"source_id",
"source_type"
],
"destination_type": "entity",
"destination_columns": [
"id",
"type"
],
"prefix": "source"
},
{
"id": "55555555-5555-5555-5555-555555555555",
"type": "relation",
"constraint": "fk_relationship_target_entity",
"source_type": "relationship",
"source_columns": [
"target_id",
"target_type"
],
"destination_type": "entity",
"destination_columns": [
"id",
"type"
],
"prefix": "target"
}
],
"types": [
{
"name": "entity",
"schemas": [
{
"$id": "entity",
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string"
},
"archived": {
"type": "boolean"
},
"created_by": {
"type": "string"
},
"modified_by": {
"type": "string"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"modified_at": {
"type": "string",
"format": "date-time"
}
},
"required": [
"id",
"type",
"created_by",
"created_at",
"modified_by",
"modified_at"
]
}
],
"hierarchy": [
"entity"
],
"fields": [
"id",
"type",
"created_at",
"created_by",
"modified_at",
"modified_by",
"archived"
],
"grouped_fields": {
"entity": [
"id",
"type",
"created_at",
"created_by",
"modified_at",
"modified_by",
"archived"
]
},
"lookup_fields": [],
"historical": true,
"notify": true,
"relationship": false
},
{
"name": "organization",
"schemas": [
{
"$id": "organization",
"$ref": "entity",
"properties": {
"name": {
"type": "string"
}
}
}
],
"hierarchy": [
"organization",
"entity"
],
"fields": [
"id",
"type",
"name",
"created_at",
"created_by",
"modified_at",
"modified_by",
"archived"
],
"grouped_fields": {
"organization": [
"id",
"type",
"name"
],
"entity": [
"id",
"type",
"created_at",
"created_by",
"modified_at",
"modified_by",
"archived"
]
},
"lookup_fields": [],
"historical": true,
"notify": true,
"relationship": false
},
{
"name": "user",
"schemas": [
{
"$id": "user",
"$ref": "organization",
"properties": {}
}
],
"hierarchy": [
"user",
"organization",
"entity"
],
"fields": [
"id",
"type",
"name",
"created_at",
"created_by",
"modified_at",
"modified_by",
"archived"
],
"grouped_fields": {
"user": [
"id",
"type"
],
"organization": [
"id",
"type",
"name"
],
"entity": [
"id",
"type",
"created_at",
"created_by",
"modified_at",
"modified_by",
"archived"
]
},
"lookup_fields": [],
"historical": true,
"notify": true,
"relationship": false
},
{
"name": "person",
"schemas": [
{
"$id": "person",
"$ref": "user",
"properties": {
"first_name": {
"type": "string"
},
"last_name": {
"type": "string"
},
"date_of_birth": {
"type": "string"
},
"pronouns": {
"type": "string"
},
"contact_id": {
"type": "string"
},
"contacts": {
"type": "array",
"items": {
"$ref": "contact",
"properties": {
"target": {
"oneOf": [
{
"$ref": "phone_number"
},
{
"$ref": "email_address"
}
]
}
}
}
}
}
}
],
"hierarchy": [
"person",
"user",
"organization",
"entity"
],
"fields": [
"id",
"type",
"first_name",
"last_name",
"date_of_birth",
"pronouns",
"contact_id",
"name",
"created_at",
"created_by",
"modified_at",
"modified_by",
"archived"
],
"grouped_fields": {
"person": [
"id",
"type",
"first_name",
"last_name",
"date_of_birth",
"pronouns",
"contact_id"
],
"user": [
"id",
"type"
],
"organization": [
"id",
"type",
"name"
],
"entity": [
"id",
"type",
"created_at",
"created_by",
"modified_at",
"modified_by",
"archived"
]
},
"lookup_fields": [
"first_name",
"last_name",
"date_of_birth",
"pronouns"
],
"historical": true,
"notify": true,
"relationship": false
},
{
"name": "order",
"schemas": [
{
"$id": "order",
"$ref": "entity",
"properties": {
"total": {
"type": "number"
},
"customer_id": {
"type": "string"
},
"customer": {
"$ref": "person"
},
"lines": {
"type": "array",
"items": {
"$ref": "order_line"
}
}
}
}
],
"hierarchy": [
"order",
"entity"
],
"fields": [
"id",
"type",
"total",
"customer_id",
"created_at",
"created_by",
"modified_at",
"modified_by",
"archived"
],
"grouped_fields": {
"order": [
"id",
"type",
"total",
"customer_id"
],
"entity": [
"id",
"type",
"created_at",
"created_by",
"modified_at",
"modified_by",
"archived"
]
},
"lookup_fields": [
"id"
],
"historical": true,
"notify": true,
"relationship": false
},
{
"name": "order_line",
"schemas": [
{
"$id": "order_line",
"$ref": "entity",
"properties": {
"order_id": {
"type": "string"
},
"product": {
"type": "string"
},
"price": {
"type": "number"
}
}
}
],
"hierarchy": [
"order_line",
"entity"
],
"fields": [
"id",
"type",
"order_id",
"product",
"price",
"created_at",
"created_by",
"modified_at",
"modified_by",
"archived"
],
"grouped_fields": {
"order_line": [
"id",
"type",
"order_id",
"product",
"price"
],
"entity": [
"id",
"type",
"created_at",
"created_by",
"modified_at",
"modified_by",
"archived"
]
},
"lookup_fields": [],
"historical": true,
"notify": true,
"relationship": false
},
{
"name": "relationship",
"relationship": true,
"hierarchy": [
"relationship",
"entity"
],
"fields": [
"source_id",
"source_type",
"target_id",
"target_type",
"id",
"type",
"name",
"archived",
"created_at",
"created_by",
"modified_at",
"modified_by"
],
"grouped_fields": {
"entity": [
"id",
"type",
"name",
"archived",
"created_at",
"created_by",
"modified_at",
"modified_by"
],
"relationship": [
"source_id",
"source_type",
"target_id",
"target_type"
]
},
"field_types": {
"id": "uuid",
"type": "text",
"archived": "boolean",
"source_id": "uuid",
"source_type": "text",
"target_id": "uuid",
"target_type": "text",
"name": "text",
"created_at": "timestamptz",
"created_by": "uuid",
"modified_at": "timestamptz",
"modified_by": "uuid"
},
"schemas": [
{
"$id": "relationship",
"$ref": "entity",
"properties": {}
}
],
"lookup_fields": [],
"historical": true,
"notify": true
},
{
"name": "contact",
"relationship": true,
"hierarchy": [
"contact",
"relationship",
"entity"
],
"fields": [
"is_primary",
"source_id",
"source_type",
"target_id",
"target_type",
"id",
"type",
"name",
"archived",
"created_at",
"created_by",
"modified_at",
"modified_by"
],
"grouped_fields": {
"entity": [
"id",
"type",
"name",
"archived",
"created_at",
"created_by",
"modified_at",
"modified_by"
],
"relationship": [
"source_id",
"source_type",
"target_id",
"target_type"
],
"contact": [
"is_primary"
]
},
"field_types": {
"id": "uuid",
"type": "text",
"archived": "boolean",
"source_id": "uuid",
"source_type": "text",
"target_id": "uuid",
"target_type": "text",
"is_primary": "boolean",
"name": "text",
"created_at": "timestamptz",
"created_by": "uuid",
"modified_at": "timestamptz",
"modified_by": "uuid"
},
"schemas": [
{
"$id": "contact",
"$ref": "relationship",
"properties": {
"is_primary": {
"type": "boolean"
}
}
}
],
"lookup_fields": [],
"historical": true,
"notify": true
},
{
"name": "phone_number",
"hierarchy": [
"phone_number",
"entity"
],
"fields": [
"number",
"id",
"type",
"name",
"archived",
"created_at",
"created_by",
"modified_at",
"modified_by"
],
"grouped_fields": {
"entity": [
"id",
"type",
"name",
"archived",
"created_at",
"created_by",
"modified_at",
"modified_by"
],
"phone_number": [
"number"
]
},
"field_types": {
"id": "uuid",
"type": "text",
"archived": "boolean",
"number": "text",
"name": "text",
"created_at": "timestamptz",
"created_by": "uuid",
"modified_at": "timestamptz",
"modified_by": "uuid"
},
"schemas": [
{
"$id": "phone_number",
"$ref": "entity",
"properties": {
"number": {
"type": "string"
}
}
}
],
"lookup_fields": [],
"historical": true,
"notify": true,
"relationship": false
},
{
"name": "email_address",
"hierarchy": [
"email_address",
"entity"
],
"fields": [
"address",
"id",
"type",
"name",
"archived",
"created_at",
"created_by",
"modified_at",
"modified_by"
],
"grouped_fields": {
"entity": [
"id",
"type",
"name",
"archived",
"created_at",
"created_by",
"modified_at",
"modified_by"
],
"email_address": [
"address"
]
},
"field_types": {
"id": "uuid",
"type": "text",
"archived": "boolean",
"address": "text",
"name": "text",
"created_at": "timestamptz",
"created_by": "uuid",
"modified_at": "timestamptz",
"modified_by": "uuid"
},
"schemas": [
{
"$id": "email_address",
"$ref": "entity",
"properties": {
"address": {
"type": "string"
}
}
}
],
"lookup_fields": [],
"historical": true,
"notify": true,
"relationship": false
},
{
"name": "attachment",
"schemas": [
{
"$id": "type_metadata",
"type": "object",
"properties": {
"type": {
"type": "string"
}
}
},
{
"$id": "other_metadata",
"type": "object",
"properties": {
"other": {
"type": "string"
}
}
},
{
"$id": "attachment",
"$ref": "entity",
"properties": {
"flags": {
"type": "array",
"items": {
"type": "string"
}
},
"type_metadata": {
"$ref": "type_metadata"
},
"other_metadata": {
"$ref": "other_metadata"
}
}
}
],
"hierarchy": [
"attachment",
"entity"
],
"fields": [
"id",
"type",
"flags",
"type_metadata",
"other_metadata",
"created_at",
"created_by",
"modified_at",
"modified_by",
"archived"
],
"grouped_fields": {
"attachment": [
"id",
"type",
"flags",
"type_metadata",
"other_metadata"
],
"entity": [
"id",
"type",
"created_at",
"created_by",
"modified_at",
"modified_by",
"archived"
]
},
"field_types": {
"id": "uuid",
"type": "text",
"flags": "_text",
"type_metadata": "jsonb",
"other_metadata": "jsonb",
"created_at": "timestamptz",
"created_by": "uuid",
"modified_at": "timestamptz",
"modified_by": "uuid",
"archived": "boolean"
},
"lookup_fields": [],
"historical": true,
"notify": true,
"relationship": false
}
]
}
}

View File

@ -142,7 +142,7 @@
"errors": [
{
"code": "CONST_VIOLATED",
"path": "/con"
"path": "con"
}
]
}

View File

@ -48,7 +48,7 @@
"errors": [
{
"code": "TYPE_MISMATCH",
"path": "/base_prop"
"path": "base_prop"
}
]
}
@ -109,7 +109,7 @@
"errors": [
{
"code": "REQUIRED_FIELD_MISSING",
"path": "/a"
"path": "a"
}
]
}
@ -126,7 +126,7 @@
"errors": [
{
"code": "REQUIRED_FIELD_MISSING",
"path": "/b"
"path": "b"
}
]
}
@ -196,7 +196,7 @@
"errors": [
{
"code": "DEPENDENCY_FAILED",
"path": "/base_dep"
"path": "base_dep"
}
]
}
@ -214,7 +214,7 @@
"errors": [
{
"code": "DEPENDENCY_FAILED",
"path": "/child_dep"
"path": "child_dep"
}
]
}

View File

@ -972,7 +972,119 @@
"LEFT JOIN agreego.\"user\" t2 ON t2.id = t1.id",
"LEFT JOIN agreego.\"organization\" t3 ON t3.id = t1.id",
"LEFT JOIN agreego.\"entity\" t4 ON t4.id = t1.id",
"WHERE \"first_name\" = 'LookupFirst' AND \"last_name\" = 'LookupLast' AND \"date_of_birth\" = '1990-01-01T00:00:00Z' AND \"pronouns\" = 'they/them'"
"WHERE (",
" \"first_name\" = 'LookupFirst'",
" AND \"last_name\" = 'LookupLast'",
" AND \"date_of_birth\" = '1990-01-01T00:00:00Z'",
" AND \"pronouns\" = 'they/them'",
")"
],
[
"UPDATE agreego.\"person\"",
"SET",
" \"contact_id\" = 'abc-contact'",
"WHERE",
" id = '22222222-2222-2222-2222-222222222222'"
],
[
"UPDATE agreego.\"entity\"",
"SET",
" \"modified_at\" = '2026-03-10T00:00:00Z',",
" \"modified_by\" = '00000000-0000-0000-0000-000000000000'",
"WHERE",
" id = '22222222-2222-2222-2222-222222222222'"
],
[
"INSERT INTO agreego.change (",
" \"old\",",
" \"new\",",
" entity_id,",
" id,",
" kind,",
" modified_at,",
" modified_by",
")",
"VALUES (",
" '{",
" \"contact_id\":\"old-contact\"",
" }',",
" '{",
" \"contact_id\":\"abc-contact\",",
" \"type\":\"person\"",
" }',",
" '22222222-2222-2222-2222-222222222222',",
" '{{uuid}}',",
" 'update',",
" '{{timestamp}}',",
" '00000000-0000-0000-0000-000000000000'",
")"
],
[
"SELECT pg_notify('entity', '{",
" \"complete\":{",
" \"contact_id\":\"abc-contact\",",
" \"date_of_birth\":\"1990-01-01T00:00:00Z\",",
" \"first_name\":\"LookupFirst\",",
" \"id\":\"22222222-2222-2222-2222-222222222222\",",
" \"last_name\":\"LookupLast\",",
" \"modified_at\":\"2026-03-10T00:00:00Z\",",
" \"modified_by\":\"00000000-0000-0000-0000-000000000000\",",
" \"pronouns\":\"they/them\",",
" \"type\":\"person\"",
" },",
" \"new\":{",
" \"contact_id\":\"abc-contact\",",
" \"type\":\"person\"",
" },",
" \"old\":{",
" \"contact_id\":\"old-contact\"",
" }",
" }')"
]
]
}
},
{
"description": "Update existing person with id (lookup)",
"action": "merge",
"data": {
"id": "33333333-3333-3333-3333-333333333333",
"type": "person",
"first_name": "LookupFirst",
"last_name": "LookupLast",
"date_of_birth": "1990-01-01T00:00:00Z",
"pronouns": "they/them",
"contact_id": "abc-contact"
},
"mocks": [
{
"id": "22222222-2222-2222-2222-222222222222",
"type": "person",
"first_name": "LookupFirst",
"last_name": "LookupLast",
"date_of_birth": "1990-01-01T00:00:00Z",
"pronouns": "they/them",
"contact_id": "old-contact"
}
],
"schema_id": "person",
"expect": {
"success": true,
"sql": [
[
"SELECT to_jsonb(t1.*) || to_jsonb(t2.*) || to_jsonb(t3.*) || to_jsonb(t4.*)",
"FROM agreego.\"person\" t1",
"LEFT JOIN agreego.\"user\" t2 ON t2.id = t1.id",
"LEFT JOIN agreego.\"organization\" t3 ON t3.id = t1.id",
"LEFT JOIN agreego.\"entity\" t4 ON t4.id = t1.id",
"WHERE",
" t1.id = '33333333-3333-3333-3333-333333333333'",
" OR (",
" \"first_name\" = 'LookupFirst'",
" AND \"last_name\" = 'LookupLast'",
" AND \"date_of_birth\" = '1990-01-01T00:00:00Z'",
" AND \"pronouns\" = 'they/them'",
" )"
],
[
"UPDATE agreego.\"person\"",
@ -1484,7 +1596,7 @@
"SELECT to_jsonb(t1.*) || to_jsonb(t2.*)",
"FROM agreego.\"order\" t1",
"LEFT JOIN agreego.\"entity\" t2 ON t2.id = t1.id",
"WHERE t1.id = 'abc'"
"WHERE t1.id = 'abc' OR (\"id\" = 'abc')"
],
[
"INSERT INTO agreego.\"entity\" (",
@ -2401,6 +2513,215 @@
]
]
}
},
{
"description": "Anchor order and insert new line (no line id)",
"action": "merge",
"data": {
"id": "abc",
"type": "order",
"lines": [
{
"type": "order_line",
"product": "Widget",
"price": 99.0
}
]
},
"schema_id": "order",
"expect": {
"success": true,
"sql": [
[
"INSERT INTO agreego.\"entity\" (",
" \"created_at\",",
" \"created_by\",",
" \"id\",",
" \"modified_at\",",
" \"modified_by\",",
" \"type\"",
")",
"VALUES (",
" '{{timestamp}}',",
" '00000000-0000-0000-0000-000000000000',",
" '{{uuid:line_id}}',",
" '{{timestamp}}',",
" '00000000-0000-0000-0000-000000000000',",
" 'order_line'",
")"
],
[
"INSERT INTO agreego.\"order_line\" (",
" \"id\",",
" \"order_id\",",
" \"price\",",
" \"product\",",
" \"type\"",
")",
"VALUES (",
" '{{uuid:line_id}}',",
" 'abc',",
" 99,",
" 'Widget',",
" 'order_line'",
")"
],
[
"INSERT INTO agreego.change (",
" \"old\",",
" \"new\",",
" entity_id,",
" id,",
" kind,",
" modified_at,",
" modified_by",
")",
"VALUES (",
" NULL,",
" '{",
" \"order_id\":\"abc\",",
" \"price\":99.0,",
" \"product\":\"Widget\",",
" \"type\":\"order_line\"",
" }',",
" '{{uuid:line_id}}',",
" '{{uuid}}',",
" 'create',",
" '{{timestamp}}',",
" '00000000-0000-0000-0000-000000000000'",
")"
],
[
"SELECT pg_notify('entity', '{",
" \"complete\":{",
" \"created_at\":\"{{timestamp}}\",",
" \"created_by\":\"00000000-0000-0000-0000-000000000000\",",
" \"id\":\"{{uuid:line_id}}\",",
" \"modified_at\":\"{{timestamp}}\",",
" \"modified_by\":\"00000000-0000-0000-0000-000000000000\",",
" \"order_id\":\"abc\",",
" \"price\":99.0,",
" \"product\":\"Widget\",",
" \"type\":\"order_line\"",
" },",
" \"new\":{",
" \"order_id\":\"abc\",",
" \"price\":99.0,",
" \"product\":\"Widget\",",
" \"type\":\"order_line\"",
" }",
" }')"
]
]
}
},
{
"description": "Anchor order and insert new line (with line id)",
"action": "merge",
"data": {
"id": "abc",
"type": "order",
"lines": [
{
"id": "11111111-2222-3333-4444-555555555555",
"type": "order_line",
"product": "Widget",
"price": 99.0
}
]
},
"schema_id": "order",
"expect": {
"success": true,
"sql": [
[
"SELECT to_jsonb(t1.*) || to_jsonb(t2.*)",
"FROM agreego.\"order_line\" t1",
"LEFT JOIN agreego.\"entity\" t2 ON t2.id = t1.id",
"WHERE t1.id = '11111111-2222-3333-4444-555555555555'"
],
[
"INSERT INTO agreego.\"entity\" (",
" \"created_at\",",
" \"created_by\",",
" \"id\",",
" \"modified_at\",",
" \"modified_by\",",
" \"type\"",
")",
"VALUES (",
" '{{timestamp}}',",
" '00000000-0000-0000-0000-000000000000',",
" '11111111-2222-3333-4444-555555555555',",
" '{{timestamp}}',",
" '00000000-0000-0000-0000-000000000000',",
" 'order_line'",
")"
],
[
"INSERT INTO agreego.\"order_line\" (",
" \"id\",",
" \"order_id\",",
" \"price\",",
" \"product\",",
" \"type\"",
")",
"VALUES (",
" '11111111-2222-3333-4444-555555555555',",
" 'abc',",
" 99,",
" 'Widget',",
" 'order_line'",
")"
],
[
"INSERT INTO agreego.change (",
" \"old\",",
" \"new\",",
" entity_id,",
" id,",
" kind,",
" modified_at,",
" modified_by",
")",
"VALUES (",
" NULL,",
" '{",
" \"order_id\":\"abc\",",
" \"price\":99.0,",
" \"product\":\"Widget\",",
" \"type\":\"order_line\"",
" }',",
" '11111111-2222-3333-4444-555555555555',",
" '{{uuid}}',",
" 'create',",
" '{{timestamp}}',",
" '00000000-0000-0000-0000-000000000000'",
")"
],
[
"SELECT pg_notify('entity', '{",
" \"complete\":{",
" \"created_at\":\"{{timestamp}}\",",
" \"created_by\":\"00000000-0000-0000-0000-000000000000\",",
" \"id\":\"11111111-2222-3333-4444-555555555555\",",
" \"modified_at\":\"{{timestamp}}\",",
" \"modified_by\":\"00000000-0000-0000-0000-000000000000\",",
" \"order_id\":\"abc\",",
" \"price\":99.0,",
" \"product\":\"Widget\",",
" \"type\":\"order_line\"",
" },",
" \"new\":{",
" \"order_id\":\"abc\",",
" \"price\":99.0,",
" \"product\":\"Widget\",",
" \"type\":\"order_line\"",
" }",
" }')"
]
]
}
}
]
}

214
fixtures/paths.json Normal file
View File

@ -0,0 +1,214 @@
[
{
"description": "Hybrid Array Pathing",
"database": {
"schemas": [
{
"$id": "hybrid_pathing",
"type": "object",
"properties": {
"primitives": {
"type": "array",
"items": {
"type": "string"
}
},
"ad_hoc_objects": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string"
}
},
"required": [
"name"
]
}
},
"entities": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"value": {
"type": "number",
"minimum": 10
}
}
}
},
"deep_entities": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"nested": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"flag": {
"type": "boolean"
}
}
}
}
}
}
}
}
}
]
},
"tests": [
{
"description": "happy path passes structural validation",
"data": {
"primitives": [
"a",
"b"
],
"ad_hoc_objects": [
{
"name": "obj1"
}
],
"entities": [
{
"id": "entity-1",
"value": 15
}
],
"deep_entities": [
{
"id": "parent-1",
"nested": [
{
"id": "child-1",
"flag": true
}
]
}
]
},
"schema_id": "hybrid_pathing",
"action": "validate",
"expect": {
"success": true
}
},
{
"description": "primitive arrays use numeric indexing",
"data": {
"primitives": [
"a",
123
]
},
"schema_id": "hybrid_pathing",
"action": "validate",
"expect": {
"success": false,
"errors": [
{
"code": "INVALID_TYPE",
"path": "primitives/1"
}
]
}
},
{
"description": "ad-hoc objects without ids use numeric indexing",
"data": {
"ad_hoc_objects": [
{
"name": "valid"
},
{
"age": 30
}
]
},
"schema_id": "hybrid_pathing",
"action": "validate",
"expect": {
"success": false,
"errors": [
{
"code": "REQUIRED_FIELD_MISSING",
"path": "ad_hoc_objects/1/name"
}
]
}
},
{
"description": "arrays of objects with ids use topological uuid indexing",
"data": {
"entities": [
{
"id": "entity-alpha",
"value": 20
},
{
"id": "entity-beta",
"value": 5
}
]
},
"schema_id": "hybrid_pathing",
"action": "validate",
"expect": {
"success": false,
"errors": [
{
"code": "MINIMUM_VIOLATED",
"path": "entities/entity-beta/value"
}
]
}
},
{
"description": "deeply nested entity arrays retain full topological paths",
"data": {
"deep_entities": [
{
"id": "parent-omega",
"nested": [
{
"id": "child-alpha",
"flag": true
},
{
"id": "child-beta",
"flag": "invalid-string"
}
]
}
]
},
"schema_id": "hybrid_pathing",
"action": "validate",
"expect": {
"success": false,
"errors": [
{
"code": "INVALID_TYPE",
"path": "deep_entities/parent-omega/nested/child-beta/flag"
}
]
}
}
]
}
]

View File

@ -20,6 +20,16 @@
"$family": "base.person"
}
]
},
{
"name": "get_orders",
"schemas": [
{
"$id": "get_orders.response",
"type": "array",
"items": { "$ref": "light.order" }
}
]
}
],
"enums": [],
@ -664,6 +674,15 @@
}
}
},
{
"$id": "light.order",
"$ref": "order",
"properties": {
"customer": {
"$ref": "base.person"
}
}
},
{
"$id": "full.order",
"$ref": "order",
@ -1003,6 +1022,7 @@
" JOIN agreego.entity entity_6 ON entity_6.id = relationship_5.id",
" WHERE",
" NOT entity_6.archived",
" AND relationship_5.target_type = 'address'",
" AND relationship_5.source_id = entity_3.id),",
" 'age', person_1.age,",
" 'archived', entity_3.archived,",
@ -1094,6 +1114,7 @@
" JOIN agreego.entity entity_20 ON entity_20.id = relationship_19.id",
" WHERE",
" NOT entity_20.archived",
" AND relationship_19.target_type = 'email_address'",
" AND relationship_19.source_id = entity_3.id),",
" 'first_name', person_1.first_name,",
" 'id', entity_3.id,",
@ -1127,6 +1148,7 @@
" JOIN agreego.entity entity_25 ON entity_25.id = relationship_24.id",
" WHERE",
" NOT entity_25.archived",
" AND relationship_24.target_type = 'phone_number'",
" AND relationship_24.source_id = entity_3.id),",
" 'type', entity_3.type",
")",
@ -1163,7 +1185,7 @@
"$eq": true,
"$ne": false
},
"contacts.#.is_primary": {
"contacts/is_primary": {
"$eq": true
},
"created_at": {
@ -1203,7 +1225,7 @@
"$eq": "%Doe%",
"$ne": "%Smith%"
},
"phone_numbers.#.target.number": {
"phone_numbers/target/number": {
"$eq": "555-1234"
}
},
@ -1240,6 +1262,7 @@
" JOIN agreego.entity entity_6 ON entity_6.id = relationship_5.id",
" WHERE",
" NOT entity_6.archived",
" AND relationship_5.target_type = 'address'",
" AND relationship_5.source_id = entity_3.id),",
" 'age', person_1.age,",
" 'archived', entity_3.archived,",
@ -1332,6 +1355,7 @@
" JOIN agreego.entity entity_20 ON entity_20.id = relationship_19.id",
" WHERE",
" NOT entity_20.archived",
" AND relationship_19.target_type = 'email_address'",
" AND relationship_19.source_id = entity_3.id),",
" 'first_name', person_1.first_name,",
" 'id', entity_3.id,",
@ -1366,6 +1390,7 @@
" JOIN agreego.entity entity_25 ON entity_25.id = relationship_24.id",
" WHERE",
" NOT entity_25.archived",
" AND relationship_24.target_type = 'phone_number'",
" AND relationship_24.source_id = entity_3.id),",
" 'type', entity_3.type",
")",
@ -1408,6 +1433,46 @@
]
}
},
{
"description": "Person ad-hoc email addresses select",
"action": "query",
"schema_id": "full.person/email_addresses",
"expect": {
"success": true,
"sql": [
[
"(SELECT jsonb_build_object(",
" 'archived', entity_3.archived,",
" 'created_at', entity_3.created_at,",
" 'id', entity_3.id,",
" 'is_primary', contact_1.is_primary,",
" 'name', entity_3.name,",
" 'target',",
" (SELECT jsonb_build_object(",
" 'address', email_address_4.address,",
" 'archived', entity_5.archived,",
" 'created_at', entity_5.created_at,",
" 'id', entity_5.id,",
" 'name', entity_5.name,",
" 'type', entity_5.type",
" )",
" FROM agreego.email_address email_address_4",
" JOIN agreego.entity entity_5 ON entity_5.id = email_address_4.id",
" WHERE",
" NOT entity_5.archived",
" AND relationship_2.target_id = entity_5.id),",
" 'type', entity_3.type",
")",
"FROM agreego.contact contact_1",
"JOIN agreego.relationship relationship_2 ON relationship_2.id = contact_1.id",
"JOIN agreego.entity entity_3 ON entity_3.id = relationship_2.id",
"WHERE",
" NOT entity_3.archived",
" AND relationship_2.target_type = 'email_address')"
]
]
}
},
{
"description": "Order select with customer and lines",
"action": "query",
@ -1523,6 +1588,47 @@
]
]
}
},
{
"description": "Root Array SQL evaluation for Order fetching Light Order",
"action": "query",
"schema_id": "get_orders.response",
"expect": {
"success": true,
"sql": [
[
"(SELECT COALESCE(jsonb_agg(jsonb_build_object(",
" 'archived', entity_2.archived,",
" 'created_at', entity_2.created_at,",
" 'customer',",
" (SELECT jsonb_build_object(",
" 'age', person_3.age,",
" 'archived', entity_5.archived,",
" 'created_at', entity_5.created_at,",
" 'first_name', person_3.first_name,",
" 'id', entity_5.id,",
" 'last_name', person_3.last_name,",
" 'name', entity_5.name,",
" 'type', entity_5.type",
" )",
" FROM agreego.person person_3",
" JOIN agreego.organization organization_4 ON organization_4.id = person_3.id",
" JOIN agreego.entity entity_5 ON entity_5.id = organization_4.id",
" WHERE",
" NOT entity_5.archived",
" AND order_1.customer_id = person_3.id),",
" 'customer_id', order_1.customer_id,",
" 'id', entity_2.id,",
" 'name', entity_2.name,",
" 'total', order_1.total,",
" 'type', entity_2.type",
")), '[]'::jsonb)",
"FROM agreego.order order_1",
"JOIN agreego.entity entity_2 ON entity_2.id = order_1.id",
"WHERE NOT entity_2.archived)"
]
]
}
}
]
}

View File

@ -677,7 +677,7 @@
"errors": [
{
"code": "TYPE_MISMATCH",
"path": "/type"
"path": "type"
}
]
}
@ -782,7 +782,7 @@
"errors": [
{
"code": "TYPE_MISMATCH",
"path": "/type"
"path": "type"
}
]
}

View File

@ -124,42 +124,23 @@ fn parse_and_match_mocks(sql: &str, mocks: &[Value]) -> Option<Vec<Value>> {
return None;
};
// 2. Extract WHERE conditions
let mut conditions = Vec::new();
// 2. Extract WHERE conditions string
let mut where_clause = String::new();
if let Some(where_idx) = sql_upper.find(" WHERE ") {
let mut where_end = sql_upper.find(" ORDER BY ").unwrap_or(sql.len());
let mut where_end = sql_upper.find(" ORDER BY ").unwrap_or(sql_upper.len());
if let Some(limit_idx) = sql_upper.find(" LIMIT ") {
if limit_idx < where_end {
where_end = limit_idx;
}
}
let where_clause = &sql[where_idx + 7..where_end];
let and_regex = Regex::new(r"(?i)\s+AND\s+").ok()?;
let parts = and_regex.split(where_clause);
for part in parts {
if let Some(eq_idx) = part.find('=') {
let left = part[..eq_idx]
.trim()
.split('.')
.last()
.unwrap_or("")
.trim_matches('"');
let right = part[eq_idx + 1..].trim().trim_matches('\'');
conditions.push((left.to_string(), right.to_string()));
} else if part.to_uppercase().contains(" IS NULL") {
let left = part[..part.to_uppercase().find(" IS NULL").unwrap()]
.trim()
.split('.')
.last()
.unwrap_or("")
.replace('"', ""); // Remove quotes explicitly
conditions.push((left, "null".to_string()));
}
}
where_clause = sql[where_idx + 7..where_end].to_string();
}
// 3. Find matching mocks
let mut matches = Vec::new();
let or_regex = Regex::new(r"(?i)\s+OR\s+").ok()?;
let and_regex = Regex::new(r"(?i)\s+AND\s+").ok()?;
for mock in mocks {
if let Some(mock_obj) = mock.as_object() {
if let Some(t) = mock_obj.get("type") {
@ -168,25 +149,66 @@ fn parse_and_match_mocks(sql: &str, mocks: &[Value]) -> Option<Vec<Value>> {
}
}
let mut matches_all = true;
for (k, v) in &conditions {
let mock_val_str = match mock_obj.get(k) {
Some(Value::String(s)) => s.clone(),
Some(Value::Number(n)) => n.to_string(),
Some(Value::Bool(b)) => b.to_string(),
Some(Value::Null) => "null".to_string(),
_ => {
matches_all = false;
break;
if where_clause.is_empty() {
matches.push(mock.clone());
continue;
}
let or_parts = or_regex.split(&where_clause);
let mut any_branch_matched = false;
for or_part in or_parts {
let branch_str = or_part.replace('(', "").replace(')', "");
let mut branch_matches = true;
for part in and_regex.split(&branch_str) {
if let Some(eq_idx) = part.find('=') {
let left = part[..eq_idx]
.trim()
.split('.')
.last()
.unwrap_or("")
.trim_matches('"');
let right = part[eq_idx + 1..].trim().trim_matches('\'');
let mock_val_str = match mock_obj.get(left) {
Some(Value::String(s)) => s.clone(),
Some(Value::Number(n)) => n.to_string(),
Some(Value::Bool(b)) => b.to_string(),
Some(Value::Null) => "null".to_string(),
_ => "".to_string(),
};
if mock_val_str != right {
branch_matches = false;
break;
}
} else if part.to_uppercase().contains(" IS NULL") {
let left = part[..part.to_uppercase().find(" IS NULL").unwrap()]
.trim()
.split('.')
.last()
.unwrap_or("")
.trim_matches('"');
let mock_val_str = match mock_obj.get(left) {
Some(Value::Null) => "null".to_string(),
_ => "".to_string(),
};
if mock_val_str != "null" {
branch_matches = false;
break;
}
}
};
if mock_val_str != *v {
matches_all = false;
}
if branch_matches {
any_branch_matched = true;
break;
}
}
if matches_all {
if any_branch_matched {
matches.push(mock.clone());
}
}

View File

@ -9,6 +9,61 @@ impl SpiExecutor {
pub fn new() -> Self {
Self {}
}
fn transact<F, R>(&self, f: F) -> Result<R, String>
where
F: FnOnce() -> Result<R, String>,
{
unsafe {
let oldcontext = pgrx::pg_sys::CurrentMemoryContext;
let oldowner = pgrx::pg_sys::CurrentResourceOwner;
pgrx::pg_sys::BeginInternalSubTransaction(std::ptr::null());
pgrx::pg_sys::MemoryContextSwitchTo(oldcontext);
let runner = std::panic::AssertUnwindSafe(move || {
let res = f();
pgrx::pg_sys::ReleaseCurrentSubTransaction();
pgrx::pg_sys::MemoryContextSwitchTo(oldcontext);
pgrx::pg_sys::CurrentResourceOwner = oldowner;
res
});
pgrx::PgTryBuilder::new(runner)
.catch_rust_panic(|cause| {
pgrx::pg_sys::RollbackAndReleaseCurrentSubTransaction();
pgrx::pg_sys::MemoryContextSwitchTo(oldcontext);
pgrx::pg_sys::CurrentResourceOwner = oldowner;
// Rust panics are fatal bugs, not validation errors. Rethrow so they bubble up.
cause.rethrow()
})
.catch_others(|cause| {
pgrx::pg_sys::RollbackAndReleaseCurrentSubTransaction();
pgrx::pg_sys::MemoryContextSwitchTo(oldcontext);
pgrx::pg_sys::CurrentResourceOwner = oldowner;
let error_msg = match &cause {
pgrx::pg_sys::panic::CaughtError::PostgresError(e)
| pgrx::pg_sys::panic::CaughtError::ErrorReport(e) => {
let json_err = serde_json::json!({
"error": e.message(),
"code": format!("{:?}", e.sql_error_code()),
"detail": e.detail(),
"hint": e.hint()
});
json_err.to_string()
}
_ => format!("{:?}", cause),
};
pgrx::warning!("JSPG Caught Native Postgres Error: {}", error_msg);
Err(error_msg)
})
.execute()
}
}
}
impl DatabaseExecutor for SpiExecutor {
@ -24,7 +79,7 @@ impl DatabaseExecutor for SpiExecutor {
}
}
pgrx::PgTryBuilder::new(|| {
self.transact(|| {
Spi::connect(|client| {
pgrx::notice!("JSPG_SQL: {}", sql);
match client.select(sql, Some(args_with_oid.len() as i64), &args_with_oid) {
@ -41,11 +96,6 @@ impl DatabaseExecutor for SpiExecutor {
}
})
})
.catch_others(|cause| {
pgrx::warning!("JSPG Caught Native Postgres Error: {:?}", cause);
Err(format!("{:?}", cause))
})
.execute()
}
fn execute(&self, sql: &str, args: Option<&[Value]>) -> Result<(), String> {
@ -60,7 +110,7 @@ impl DatabaseExecutor for SpiExecutor {
}
}
pgrx::PgTryBuilder::new(|| {
self.transact(|| {
Spi::connect_mut(|client| {
pgrx::notice!("JSPG_SQL: {}", sql);
match client.update(sql, Some(args_with_oid.len() as i64), &args_with_oid) {
@ -69,44 +119,43 @@ impl DatabaseExecutor for SpiExecutor {
}
})
})
.catch_others(|cause| {
pgrx::warning!("JSPG Caught Native Postgres Error: {:?}", cause);
Err(format!("{:?}", cause))
})
.execute()
}
fn auth_user_id(&self) -> Result<String, String> {
Spi::connect(|client| {
let mut tup_table = client
.select(
"SELECT COALESCE(current_setting('auth.user_id', true), 'ffffffff-ffff-ffff-ffff-ffffffffffff')",
None,
&[],
)
.map_err(|e| format!("SPI Select Error: {}", e))?;
self.transact(|| {
Spi::connect(|client| {
let mut tup_table = client
.select(
"SELECT COALESCE(current_setting('auth.user_id', true), 'ffffffff-ffff-ffff-ffff-ffffffffffff')",
None,
&[],
)
.map_err(|e| format!("SPI Select Error: {}", e))?;
let row = tup_table
.next()
.ok_or("No user id setting returned from context".to_string())?;
let user_id: Option<String> = row.get(1).map_err(|e| e.to_string())?;
let row = tup_table
.next()
.ok_or("No user id setting returned from context".to_string())?;
let user_id: Option<String> = row.get(1).map_err(|e| e.to_string())?;
user_id.ok_or("Missing user_id".to_string())
user_id.ok_or("Missing user_id".to_string())
})
})
}
fn timestamp(&self) -> Result<String, String> {
Spi::connect(|client| {
let mut tup_table = client
.select("SELECT clock_timestamp()::text", None, &[])
.map_err(|e| format!("SPI Select Error: {}", e))?;
self.transact(|| {
Spi::connect(|client| {
let mut tup_table = client
.select("SELECT clock_timestamp()::text", None, &[])
.map_err(|e| format!("SPI Select Error: {}", e))?;
let row = tup_table
.next()
.ok_or("No clock timestamp returned".to_string())?;
let timestamp: Option<String> = row.get(1).map_err(|e| e.to_string())?;
let row = tup_table
.next()
.ok_or("No clock timestamp returned".to_string())?;
let timestamp: Option<String> = row.get(1).map_err(|e| e.to_string())?;
timestamp.ok_or("Missing timestamp".to_string())
timestamp.ok_or("Missing timestamp".to_string())
})
})
}
}

View File

@ -79,7 +79,18 @@ impl Database {
db.relations.insert(def.constraint.clone(), def);
}
}
Err(e) => println!("DATABASE RELATION PARSE FAILED: {:?}", e),
Err(e) => {
return Err(crate::drop::Drop::with_errors(vec![crate::drop::Error {
code: "DATABASE_RELATION_PARSE_FAILED".to_string(),
message: format!("Failed to parse database relation: {}", e),
details: crate::drop::ErrorDetails {
path: "".to_string(),
cause: None,
context: None,
schema: None,
},
}]));
}
}
}
}
@ -137,7 +148,30 @@ impl Database {
}
pub fn compile(&mut self) -> Result<(), crate::drop::Drop> {
self.collect_schemas();
let mut harvested = Vec::new();
for schema in self.schemas.values_mut() {
if let Err(msg) = schema.collect_schemas(None, &mut harvested) {
return Err(crate::drop::Drop::with_errors(vec![crate::drop::Error {
code: "SCHEMA_VALIDATION_FAILED".to_string(),
message: msg,
details: crate::drop::ErrorDetails { path: "".to_string(), cause: None, context: None, schema: None },
}]));
}
}
self.schemas.extend(harvested);
if let Err(msg) = self.collect_schemas() {
return Err(crate::drop::Drop::with_errors(vec![crate::drop::Error {
code: "SCHEMA_VALIDATION_FAILED".to_string(),
message: msg,
details: crate::drop::ErrorDetails {
path: "".to_string(),
cause: None,
context: None,
schema: None,
},
}]));
}
self.collect_depths();
self.collect_descendants();
@ -150,29 +184,31 @@ impl Database {
Ok(())
}
fn collect_schemas(&mut self) {
fn collect_schemas(&mut self) -> Result<(), String> {
let mut to_insert = Vec::new();
// Pass 1: Extract all Schemas structurally off top level definitions into the master registry.
// Validate every node recursively via string filters natively!
for type_def in self.types.values() {
for mut schema in type_def.schemas.clone() {
schema.harvest(&mut to_insert);
schema.collect_schemas(None, &mut to_insert)?;
}
}
for punc_def in self.puncs.values() {
for mut schema in punc_def.schemas.clone() {
schema.harvest(&mut to_insert);
schema.collect_schemas(None, &mut to_insert)?;
}
}
for enum_def in self.enums.values() {
for mut schema in enum_def.schemas.clone() {
schema.harvest(&mut to_insert);
schema.collect_schemas(None, &mut to_insert)?;
}
}
for (id, schema) in to_insert {
self.schemas.insert(id, schema);
}
Ok(())
}
fn collect_depths(&mut self) {
@ -228,82 +264,6 @@ impl Database {
self.descendants = descendants;
}
fn resolve_relation(
&self,
parent_type: &str,
child_type: &str,
prop_name: &str,
relative_keys: Option<&Vec<String>>,
) -> Option<(&Relation, bool)> {
if parent_type == "entity" && child_type == "entity" {
return None; // Ignore entity <-> entity generic fallbacks, they aren't useful edges
}
let p_def = self.types.get(parent_type)?;
let c_def = self.types.get(child_type)?;
let mut matching_rels = Vec::new();
let mut directions = Vec::new();
for rel in self.relations.values() {
let is_forward = p_def.hierarchy.contains(&rel.source_type)
&& c_def.hierarchy.contains(&rel.destination_type);
let is_reverse = p_def.hierarchy.contains(&rel.destination_type)
&& c_def.hierarchy.contains(&rel.source_type);
if is_forward {
matching_rels.push(rel);
directions.push(true);
} else if is_reverse {
matching_rels.push(rel);
directions.push(false);
}
}
if matching_rels.is_empty() {
return None;
}
if matching_rels.len() == 1 {
return Some((matching_rels[0], directions[0]));
}
let mut chosen_idx = 0;
let mut resolved = false;
// Reduce ambiguity with prefix
for (i, rel) in matching_rels.iter().enumerate() {
if let Some(prefix) = &rel.prefix {
if prop_name.starts_with(prefix)
|| prefix.starts_with(prop_name)
|| prefix.replace("_", "") == prop_name.replace("_", "")
{
chosen_idx = i;
resolved = true;
break;
}
}
}
// Reduce ambiguity by checking if relative payload OMITS the prefix (M:M heuristic)
if !resolved && relative_keys.is_some() {
let keys = relative_keys.unwrap();
let mut missing_prefix_ids = Vec::new();
for (i, rel) in matching_rels.iter().enumerate() {
if let Some(prefix) = &rel.prefix {
if !keys.contains(prefix) {
missing_prefix_ids.push(i);
}
}
}
if missing_prefix_ids.len() == 1 {
chosen_idx = missing_prefix_ids[0];
}
}
Some((matching_rels[chosen_idx], directions[chosen_idx]))
}
fn collect_descendants_recursively(
target: &str,
direct_refs: &std::collections::HashMap<String, Vec<String>>,

View File

@ -393,67 +393,108 @@ impl Schema {
}
}
pub fn harvest(&mut self, to_insert: &mut Vec<(String, Schema)>) {
if let Some(id) = &self.obj.id {
to_insert.push((id.clone(), self.clone()));
#[allow(unused_variables)]
fn validate_identifier(id: &str, field_name: &str) -> Result<(), String> {
#[cfg(not(test))]
for c in id.chars() {
if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_' && c != '.' {
return Err(format!("Invalid character '{}' in JSON Schema '{}' property: '{}'. Identifiers must exclusively contain [a-z0-9_.]", c, field_name, id));
}
}
self.harvest_children(|child| child.harvest(to_insert));
Ok(())
}
pub fn harvest_children<F>(&mut self, mut f: F)
where
F: FnMut(&mut Schema),
{
pub fn collect_schemas(
&mut self,
tracking_path: Option<String>,
to_insert: &mut Vec<(String, Schema)>,
) -> Result<(), String> {
if let Some(id) = &self.obj.id {
Self::validate_identifier(id, "$id")?;
to_insert.push((id.clone(), self.clone()));
}
if let Some(r#ref) = &self.obj.r#ref {
Self::validate_identifier(r#ref, "$ref")?;
}
if let Some(family) = &self.obj.family {
Self::validate_identifier(family, "$family")?;
}
// Is this schema an inline ad-hoc composition?
// Meaning it has a tracking context, lacks an explicit $id, but extends an Entity ref with explicit properties!
if self.obj.id.is_none() && self.obj.r#ref.is_some() && self.obj.properties.is_some() {
if let Some(ref path) = tracking_path {
to_insert.push((path.clone(), self.clone()));
}
}
// Provide the path origin to children natively, prioritizing the explicit `$id` boundary if one exists
let origin_path = self.obj.id.clone().or(tracking_path);
self.collect_child_schemas(origin_path, to_insert)?;
Ok(())
}
pub fn collect_child_schemas(
&mut self,
origin_path: Option<String>,
to_insert: &mut Vec<(String, Schema)>,
) -> Result<(), String> {
if let Some(props) = &mut self.obj.properties {
for v in props.values_mut() {
for (k, v) in props.iter_mut() {
let mut inner = (**v).clone();
f(&mut inner);
let next_path = origin_path.as_ref().map(|o| format!("{}/{}", o, k));
inner.collect_schemas(next_path, to_insert)?;
*v = Arc::new(inner);
}
}
if let Some(pattern_props) = &mut self.obj.pattern_properties {
for v in pattern_props.values_mut() {
for (k, v) in pattern_props.iter_mut() {
let mut inner = (**v).clone();
f(&mut inner);
let next_path = origin_path.as_ref().map(|o| format!("{}/{}", o, k));
inner.collect_schemas(next_path, to_insert)?;
*v = Arc::new(inner);
}
}
let mut map_arr = |arr: &mut Vec<Arc<Schema>>| {
let mut map_arr = |arr: &mut Vec<Arc<Schema>>| -> Result<(), String> {
for v in arr.iter_mut() {
let mut inner = (**v).clone();
f(&mut inner);
inner.collect_schemas(origin_path.clone(), to_insert)?;
*v = Arc::new(inner);
}
Ok(())
};
if let Some(arr) = &mut self.obj.prefix_items {
map_arr(arr);
}
if let Some(arr) = &mut self.obj.all_of {
map_arr(arr);
}
if let Some(arr) = &mut self.obj.one_of {
map_arr(arr);
}
if let Some(arr) = &mut self.obj.prefix_items { map_arr(arr)?; }
if let Some(arr) = &mut self.obj.all_of { map_arr(arr)?; }
if let Some(arr) = &mut self.obj.one_of { map_arr(arr)?; }
let mut map_opt = |opt: &mut Option<Arc<Schema>>| {
let mut map_opt = |opt: &mut Option<Arc<Schema>>, pass_path: bool| -> Result<(), String> {
if let Some(v) = opt {
let mut inner = (**v).clone();
f(&mut inner);
let next = if pass_path { origin_path.clone() } else { None };
inner.collect_schemas(next, to_insert)?;
*v = Arc::new(inner);
}
Ok(())
};
map_opt(&mut self.obj.additional_properties);
map_opt(&mut self.obj.items);
map_opt(&mut self.obj.contains);
map_opt(&mut self.obj.property_names);
map_opt(&mut self.obj.not);
map_opt(&mut self.obj.if_);
map_opt(&mut self.obj.then_);
map_opt(&mut self.obj.else_);
map_opt(&mut self.obj.additional_properties, false)?;
// `items` absolutely must inherit the EXACT property path assigned to the Array wrapper!
// This allows nested Arrays enclosing bare Entity structs to correctly register as the boundary mapping.
map_opt(&mut self.obj.items, true)?;
map_opt(&mut self.obj.not, false)?;
map_opt(&mut self.obj.contains, false)?;
map_opt(&mut self.obj.property_names, false)?;
map_opt(&mut self.obj.if_, false)?;
map_opt(&mut self.obj.then_, false)?;
map_opt(&mut self.obj.else_, false)?;
Ok(())
}
pub fn compile_edges(
@ -466,10 +507,8 @@ impl Schema {
let mut parent_type_name = None;
if let Some(family) = &self.obj.family {
parent_type_name = Some(family.split('.').next_back().unwrap_or(family).to_string());
} else if let Some(id) = &self.obj.id {
parent_type_name = Some(id.split('.').next_back().unwrap_or("").to_string());
} else if let Some(ref_id) = &self.obj.r#ref {
parent_type_name = Some(ref_id.split('.').next_back().unwrap_or("").to_string());
} else if let Some(identifier) = self.obj.identifier() {
parent_type_name = Some(identifier);
}
if let Some(p_type) = parent_type_name {
@ -490,12 +529,12 @@ impl Schema {
if let Some(family) = &target_schema.obj.family {
child_type_name = Some(family.split('.').next_back().unwrap_or(family).to_string());
} else if let Some(ref_id) = target_schema.obj.r#ref.as_ref() {
child_type_name = Some(ref_id.split('.').next_back().unwrap_or("").to_string());
} else if let Some(ref_id) = target_schema.obj.identifier() {
child_type_name = Some(ref_id);
} else if let Some(arr) = &target_schema.obj.one_of {
if let Some(first) = arr.first() {
if let Some(ref_id) = first.obj.id.as_ref().or(first.obj.r#ref.as_ref()) {
child_type_name = Some(ref_id.split('.').next_back().unwrap_or("").to_string());
if let Some(ref_id) = first.obj.identifier() {
child_type_name = Some(ref_id);
}
}
}
@ -507,7 +546,7 @@ impl Schema {
let keys_for_ambiguity: Vec<String> =
compiled_target_props.keys().cloned().collect();
if let Some((relation, is_forward)) =
db.resolve_relation(&p_type, &c_type, prop_name, Some(&keys_for_ambiguity))
resolve_relation(db, &p_type, &c_type, prop_name, Some(&keys_for_ambiguity))
{
schema_edges.insert(
prop_name.clone(),
@ -527,6 +566,80 @@ impl Schema {
}
}
pub(crate) fn resolve_relation<'a>(
db: &'a crate::database::Database,
parent_type: &str,
child_type: &str,
prop_name: &str,
relative_keys: Option<&Vec<String>>,
) -> Option<(&'a crate::database::relation::Relation, bool)> {
if parent_type == "entity" && child_type == "entity" {
return None;
}
let p_def = db.types.get(parent_type)?;
let c_def = db.types.get(child_type)?;
let mut matching_rels = Vec::new();
let mut directions = Vec::new();
for rel in db.relations.values() {
let is_forward = p_def.hierarchy.contains(&rel.source_type)
&& c_def.hierarchy.contains(&rel.destination_type);
let is_reverse = p_def.hierarchy.contains(&rel.destination_type)
&& c_def.hierarchy.contains(&rel.source_type);
if is_forward {
matching_rels.push(rel);
directions.push(true);
} else if is_reverse {
matching_rels.push(rel);
directions.push(false);
}
}
if matching_rels.is_empty() {
return None;
}
if matching_rels.len() == 1 {
return Some((matching_rels[0], directions[0]));
}
let mut chosen_idx = 0;
let mut resolved = false;
for (i, rel) in matching_rels.iter().enumerate() {
if let Some(prefix) = &rel.prefix {
if prop_name.starts_with(prefix)
|| prefix.starts_with(prop_name)
|| prefix.replace("_", "") == prop_name.replace("_", "")
{
chosen_idx = i;
resolved = true;
break;
}
}
}
if !resolved && relative_keys.is_some() {
let keys = relative_keys.unwrap();
let mut missing_prefix_ids = Vec::new();
for (i, rel) in matching_rels.iter().enumerate() {
if let Some(prefix) = &rel.prefix {
if !keys.contains(prefix) {
missing_prefix_ids.push(i);
}
}
}
if missing_prefix_ids.len() == 1 {
chosen_idx = missing_prefix_ids[0];
}
}
Some((matching_rels[chosen_idx], directions[chosen_idx]))
}
impl<'de> Deserialize<'de> for Schema {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
@ -582,6 +695,16 @@ impl<'de> Deserialize<'de> for Schema {
}
}
impl SchemaObject {
pub fn identifier(&self) -> Option<String> {
if let Some(lookup_key) = self.id.as_ref().or(self.r#ref.as_ref()) {
Some(lookup_key.split('.').next_back().unwrap_or("").to_string())
} else {
None
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum SchemaTypeOrArray {

View File

@ -45,12 +45,33 @@ impl Merger {
let val_resolved = match result {
Ok(val) => val,
Err(msg) => {
let mut final_code = "MERGE_FAILED".to_string();
let mut final_message = msg.clone();
let mut final_cause = None;
if let Ok(Value::Object(map)) = serde_json::from_str::<Value>(&msg) {
if let (Some(Value::String(e_msg)), Some(Value::String(e_code))) = (map.get("error"), map.get("code")) {
final_message = e_msg.clone();
final_code = e_code.clone();
let mut cause_parts = Vec::new();
if let Some(Value::String(d)) = map.get("detail") {
if !d.is_empty() { cause_parts.push(d.clone()); }
}
if let Some(Value::String(h)) = map.get("hint") {
if !h.is_empty() { cause_parts.push(h.clone()); }
}
if !cause_parts.is_empty() {
final_cause = Some(cause_parts.join("\n"));
}
}
}
return crate::drop::Drop::with_errors(vec![crate::drop::Error {
code: "MERGE_FAILED".to_string(),
message: msg,
code: final_code,
message: final_message,
details: crate::drop::ErrorDetails {
path: "".to_string(),
cause: None,
cause: final_cause,
context: Some(data),
schema: None,
},
@ -403,6 +424,23 @@ impl Merger {
> {
let type_name = type_def.name.as_str();
// 🚀 Anchor Short-Circuit Optimization
// An anchor is STRICTLY a struct containing merely an `id` and `type`.
// We aggressively bypass Database SPI `SELECT` fetches because there are no primitive
// mutations to apply to the row. PostgreSQL inherently protects relationships via Foreign Keys downstream.
let is_anchor = entity_fields.len() == 2
&& entity_fields.contains_key("id")
&& entity_fields.contains_key("type");
let has_valid_id = entity_fields
.get("id")
.and_then(|v| v.as_str())
.map_or(false, |s| !s.is_empty());
if is_anchor && has_valid_id {
return Ok((entity_fields, None, None));
}
let entity_fetched = self.fetch_entity(&entity_fields, type_def)?;
let system_keys = vec![
@ -547,11 +585,14 @@ impl Merger {
template
};
let where_clause = if let Some(id) = id_val {
format!("WHERE t1.id = {}", Self::quote_literal(id))
} else if lookup_complete {
let mut lookup_predicates = Vec::new();
let mut where_parts = Vec::new();
if let Some(id) = id_val {
where_parts.push(format!("t1.id = {}", Self::quote_literal(id)));
}
if lookup_complete {
let mut lookup_predicates = Vec::new();
for column in &entity_type.lookup_fields {
let val = entity_fields.get(column).unwrap_or(&Value::Null);
if column == "type" {
@ -560,10 +601,14 @@ impl Merger {
lookup_predicates.push(format!("\"{}\" = {}", column, Self::quote_literal(val)));
}
}
format!("WHERE {}", lookup_predicates.join(" AND "))
} else {
where_parts.push(format!("({})", lookup_predicates.join(" AND ")));
}
if where_parts.is_empty() {
return Ok(None);
};
}
let where_clause = format!("WHERE {}", where_parts.join(" OR "));
let final_sql = format!("{} {}", fetch_sql_template, where_clause);
@ -674,8 +719,7 @@ impl Merger {
);
self
.db
.execute(&sql, None)
.map_err(|e| format!("SPI Error in INSERT: {:?}", e))?;
.execute(&sql, None)?;
} else if change_kind == "update" || change_kind == "delete" {
entity_pairs.remove("id");
entity_pairs.remove("type");
@ -709,8 +753,7 @@ impl Merger {
);
self
.db
.execute(&sql, None)
.map_err(|e| format!("SPI Error in UPDATE: {:?}", e))?;
.execute(&sql, None)?;
}
}
@ -813,10 +856,7 @@ impl Merger {
Self::quote_literal(&Value::String(user_id.to_string()))
);
self
.db
.execute(&change_sql, None)
.map_err(|e| format!("Executor Error in change: {:?}", e))?;
self.db.execute(&change_sql, None)?;
}
if type_obj.notify {

View File

@ -63,37 +63,33 @@ impl<'a> Compiler<'a> {
}
fn compile_array(&mut self, node: Node<'a>) -> Result<(String, String), String> {
// 1. Array of DB Entities (`$ref` or `$family` pointing to a table limit)
if let Some(items) = &node.schema.obj.items {
let next_path = if node.ast_path.is_empty() {
String::from("#")
} else {
format!("{}.#", node.ast_path)
};
if let Some(ref_id) = &items.obj.r#ref {
if let Some(type_def) = self.db.types.get(ref_id) {
let mut entity_node = node.clone();
entity_node.ast_path = next_path;
entity_node.schema = std::sync::Arc::clone(items);
return self.compile_entity(type_def, entity_node, true);
}
let mut resolved_type = None;
if let Some(family_target) = items.obj.family.as_ref() {
let base_type_name = family_target.split('.').next_back().unwrap_or(family_target);
resolved_type = self.db.types.get(base_type_name);
} else if let Some(base_type_name) = items.obj.identifier() {
resolved_type = self.db.types.get(&base_type_name);
}
let mut next_node = node.clone();
next_node.depth += 1;
next_node.ast_path = next_path;
next_node.schema = std::sync::Arc::clone(items);
let (item_sql, _) = self.compile_node(next_node)?;
if let Some(type_def) = resolved_type {
let mut entity_node = node.clone();
entity_node.schema = std::sync::Arc::clone(items);
return self.compile_entity(type_def, entity_node, true);
}
}
// 2. Arrays of mapped Native Postgres Columns (e.g. `jsonb`, `text[]`)
if let Some(prop) = &node.property_name {
return Ok((
format!("(SELECT jsonb_agg({}) FROM TODO)", item_sql),
format!("{}.{}", node.parent_alias, prop),
"array".to_string(),
));
}
Ok((
"SELECT jsonb_agg(TODO) FROM TODO".to_string(),
"array".to_string(),
))
// 3. Fallback for root execution of standalone non-entity arrays
Err("Cannot compile a root array without a valid entity reference or table mapped via `items`.".to_string())
}
fn compile_reference(&mut self, node: Node<'a>) -> Result<(String, String), String> {
@ -102,14 +98,7 @@ impl<'a> Compiler<'a> {
if let Some(family_target) = node.schema.obj.family.as_ref() {
resolved_type = self.db.types.get(family_target);
} else if let Some(lookup_key) = node
.schema
.obj
.id
.as_ref()
.or(node.schema.obj.r#ref.as_ref())
{
let base_type_name = lookup_key.split('.').next_back().unwrap_or("").to_string();
} else if let Some(base_type_name) = node.schema.obj.identifier() {
resolved_type = self.db.types.get(&base_type_name);
}
@ -448,22 +437,21 @@ impl<'a> Compiler<'a> {
}
}
let mut child_node = node.clone();
child_node.parent_alias = owner_alias.clone();
let arc_aliases = std::sync::Arc::new(table_aliases.clone());
child_node.parent_type_aliases = Some(arc_aliases);
child_node.parent_type = Some(r#type);
child_node.parent_schema = Some(std::sync::Arc::clone(&node.schema));
child_node.property_name = Some(prop_key.clone());
child_node.depth += 1;
let next_path = if node.ast_path.is_empty() {
prop_key.clone()
} else {
format!("{}.{}", node.ast_path, prop_key)
let child_node = Node {
schema: std::sync::Arc::clone(prop_schema),
parent_alias: owner_alias.clone(),
parent_type_aliases: Some(std::sync::Arc::new(table_aliases.clone())),
parent_type: Some(r#type),
parent_schema: Some(std::sync::Arc::clone(&node.schema)),
property_name: Some(prop_key.clone()),
depth: node.depth + 1,
ast_path: if node.ast_path.is_empty() {
prop_key.clone()
} else {
format!("{}/{}", node.ast_path, prop_key)
},
};
child_node.ast_path = next_path;
child_node.schema = std::sync::Arc::clone(prop_schema);
let (val_sql, val_type) = self.compile_node(child_node)?;
@ -491,9 +479,17 @@ impl<'a> Compiler<'a> {
.unwrap_or_else(|| base_alias.clone());
let mut where_clauses = Vec::new();
where_clauses.push(format!("NOT {}.archived", entity_alias));
// Dynamically apply the 'active-only' default ONLY if the client
// didn't explicitly request to filter on 'archived' themselves!
let has_archived_override = self.filter_keys.iter().any(|k| k == "archived");
if !has_archived_override {
where_clauses.push(format!("NOT {}.archived", entity_alias));
}
self.compile_filter_conditions(r#type, type_aliases, &node, &base_alias, &mut where_clauses);
self.compile_polymorphic_bounds(r#type, type_aliases, &node, &mut where_clauses);
self.compile_relation_conditions(
r#type,
type_aliases,
@ -505,6 +501,54 @@ impl<'a> Compiler<'a> {
Ok(where_clauses)
}
fn compile_polymorphic_bounds(
&self,
_type: &crate::database::r#type::Type,
type_aliases: &std::collections::HashMap<String, String>,
node: &Node,
where_clauses: &mut Vec<String>,
) {
if let Some(edges) = node.schema.obj.compiled_edges.get() {
if let Some(props) = node.schema.obj.compiled_properties.get() {
for (prop_name, edge) in edges {
if let Some(prop_schema) = props.get(prop_name) {
// Determine if the property schema resolves to a physical Database Entity
let mut bound_type_name = None;
if let Some(family_target) = prop_schema.obj.family.as_ref() {
bound_type_name = Some(family_target.split('.').next_back().unwrap_or(family_target).to_string());
} else if let Some(lookup_key) = prop_schema.obj.identifier() {
bound_type_name = Some(lookup_key);
}
if let Some(type_name) = bound_type_name {
// Ensure this type actually exists
if self.db.types.contains_key(&type_name) {
if let Some(relation) = self.db.relations.get(&edge.constraint) {
let mut poly_col = None;
let mut table_to_alias = "";
if edge.forward && relation.source_columns.len() > 1 {
poly_col = Some(&relation.source_columns[1]); // e.g., target_type
table_to_alias = &relation.source_type; // e.g., relationship
} else if !edge.forward && relation.destination_columns.len() > 1 {
poly_col = Some(&relation.destination_columns[1]); // e.g., source_type
table_to_alias = &relation.destination_type; // e.g., relationship
}
if let Some(col) = poly_col {
if let Some(alias) = type_aliases.get(table_to_alias).or_else(|| type_aliases.get(&node.parent_alias)) {
where_clauses.push(format!("{}.{} = '{}'", alias, col, type_name));
}
}
}
}
}
}
}
}
}
}
fn resolve_filter_alias(
r#type: &crate::database::r#type::Type,
type_aliases: &std::collections::HashMap<String, String>,
@ -580,15 +624,15 @@ impl<'a> Compiler<'a> {
let op = parts.next().unwrap_or("$eq");
let field_name = if node.ast_path.is_empty() {
if full_field_path.contains('.') || full_field_path.contains('#') {
if full_field_path.contains('/') {
continue;
}
full_field_path
} else {
let prefix = format!("{}.", node.ast_path);
let prefix = format!("{}/", node.ast_path);
if full_field_path.starts_with(&prefix) {
let remainder = &full_field_path[prefix.len()..];
if remainder.contains('.') || remainder.contains('#') {
if remainder.contains('/') {
continue;
}
remainder

View File

@ -54,6 +54,45 @@ impl Queryer {
self.execute_sql(schema_id, &sql, &args)
}
fn extract_filters(
prefix: String,
val: &serde_json::Value,
entries: &mut Vec<(String, serde_json::Value)>,
) -> Result<(), String> {
if let Some(obj) = val.as_object() {
let mut is_op_obj = false;
if let Some(first_key) = obj.keys().next() {
if first_key.starts_with('$') {
is_op_obj = true;
}
}
if is_op_obj {
for (op, op_val) in obj {
if !op.starts_with('$') {
return Err(format!("Filter operator must start with '$', got: {}", op));
}
entries.push((format!("{}:{}", prefix, op), op_val.clone()));
}
} else {
for (k, v) in obj {
let next_prefix = if prefix.is_empty() {
k.clone()
} else {
format!("{}/{}", prefix, k)
};
Self::extract_filters(next_prefix, v, entries)?;
}
}
} else {
return Err(format!(
"Filter for path '{}' must be an operator object like {{$eq: ...}} or a nested map.",
prefix
));
}
Ok(())
}
fn parse_filter_entries(
&self,
filters_map: Option<&serde_json::Map<String, serde_json::Value>>,
@ -61,19 +100,7 @@ impl Queryer {
let mut filter_entries: Vec<(String, serde_json::Value)> = Vec::new();
if let Some(fm) = filters_map {
for (key, val) in fm {
if let Some(obj) = val.as_object() {
for (op, op_val) in obj {
if !op.starts_with('$') {
return Err(format!("Filter operator must start with '$', got: {}", op));
}
filter_entries.push((format!("{}:{}", key, op), op_val.clone()));
}
} else {
return Err(format!(
"Filter for field '{}' must be an object with operators like $eq, $in, etc.",
key
));
}
Self::extract_filters(key.clone(), val, &mut filter_entries)?;
}
}
filter_entries.sort_by(|a, b| a.0.cmp(&b.0));

View File

@ -1451,6 +1451,18 @@ fn test_queryer_0_6() {
crate::tests::runner::run_test_case(&path, 0, 6).unwrap();
}
#[test]
fn test_queryer_0_7() {
let path = format!("{}/fixtures/queryer.json", env!("CARGO_MANIFEST_DIR"));
crate::tests::runner::run_test_case(&path, 0, 7).unwrap();
}
#[test]
fn test_queryer_0_8() {
let path = format!("{}/fixtures/queryer.json", env!("CARGO_MANIFEST_DIR"));
crate::tests::runner::run_test_case(&path, 0, 8).unwrap();
}
#[test]
fn test_not_0_0() {
let path = format!("{}/fixtures/not.json", env!("CARGO_MANIFEST_DIR"));
@ -2915,6 +2927,36 @@ fn test_minimum_1_6() {
crate::tests::runner::run_test_case(&path, 1, 6).unwrap();
}
#[test]
fn test_paths_0_0() {
let path = format!("{}/fixtures/paths.json", env!("CARGO_MANIFEST_DIR"));
crate::tests::runner::run_test_case(&path, 0, 0).unwrap();
}
#[test]
fn test_paths_0_1() {
let path = format!("{}/fixtures/paths.json", env!("CARGO_MANIFEST_DIR"));
crate::tests::runner::run_test_case(&path, 0, 1).unwrap();
}
#[test]
fn test_paths_0_2() {
let path = format!("{}/fixtures/paths.json", env!("CARGO_MANIFEST_DIR"));
crate::tests::runner::run_test_case(&path, 0, 2).unwrap();
}
#[test]
fn test_paths_0_3() {
let path = format!("{}/fixtures/paths.json", env!("CARGO_MANIFEST_DIR"));
crate::tests::runner::run_test_case(&path, 0, 3).unwrap();
}
#[test]
fn test_paths_0_4() {
let path = format!("{}/fixtures/paths.json", env!("CARGO_MANIFEST_DIR"));
crate::tests::runner::run_test_case(&path, 0, 4).unwrap();
}
#[test]
fn test_one_of_0_0() {
let path = format!("{}/fixtures/oneOf.json", env!("CARGO_MANIFEST_DIR"));
@ -8542,3 +8584,21 @@ fn test_merger_0_8() {
let path = format!("{}/fixtures/merger.json", env!("CARGO_MANIFEST_DIR"));
crate::tests::runner::run_test_case(&path, 0, 8).unwrap();
}
#[test]
fn test_merger_0_9() {
let path = format!("{}/fixtures/merger.json", env!("CARGO_MANIFEST_DIR"));
crate::tests::runner::run_test_case(&path, 0, 9).unwrap();
}
#[test]
fn test_merger_0_10() {
let path = format!("{}/fixtures/merger.json", env!("CARGO_MANIFEST_DIR"));
crate::tests::runner::run_test_case(&path, 0, 10).unwrap();
}
#[test]
fn test_merger_0_11() {
let path = format!("{}/fixtures/merger.json", env!("CARGO_MANIFEST_DIR"));
crate::tests::runner::run_test_case(&path, 0, 11).unwrap();
}

View File

@ -134,12 +134,12 @@ fn test_library_api() {
{
"code": "REQUIRED_FIELD_MISSING",
"message": "Missing name",
"details": { "path": "/name" }
"details": { "path": "name" }
},
{
"code": "STRICT_PROPERTY_VIOLATION",
"message": "Unexpected property 'wrong'",
"details": { "path": "/wrong" }
"details": { "path": "wrong" }
}
]
})

View File

@ -41,6 +41,14 @@ impl<'a> ValidationContext<'a> {
}
}
pub fn join_path(&self, key: &str) -> String {
if self.path.is_empty() {
key.to_string()
} else {
format!("{}/{}", self.path, key)
}
}
pub fn derive(
&self,
schema: &'a Schema,

View File

@ -91,12 +91,17 @@ impl<'a> ValidationContext<'a> {
if let Some(ref prefix) = self.schema.prefix_items {
for (i, sub_schema) in prefix.iter().enumerate() {
if i < len {
let path = format!("{}/{}", self.path, i);
if let Some(child_instance) = arr.get(i) {
let mut item_path = self.join_path(&i.to_string());
if let Some(obj) = child_instance.as_object() {
if let Some(id_str) = obj.get("id").and_then(|v| v.as_str()) {
item_path = self.join_path(id_str);
}
}
let derived = self.derive(
sub_schema,
child_instance,
&path,
&item_path,
HashSet::new(),
self.extensible,
false,
@ -112,12 +117,17 @@ impl<'a> ValidationContext<'a> {
if let Some(ref items_schema) = self.schema.items {
for i in validation_index..len {
let path = format!("{}/{}", self.path, i);
if let Some(child_instance) = arr.get(i) {
let mut item_path = self.join_path(&i.to_string());
if let Some(obj) = child_instance.as_object() {
if let Some(id_str) = obj.get("id").and_then(|v| v.as_str()) {
item_path = self.join_path(id_str);
}
}
let derived = self.derive(
items_schema,
child_instance,
&path,
&item_path,
HashSet::new(),
self.extensible,
false,

View File

@ -44,7 +44,7 @@ impl<'a> ValidationContext<'a> {
result.errors.push(ValidationError {
code: "STRICT_PROPERTY_VIOLATION".to_string(),
message: format!("Unexpected property '{}'", key),
path: format!("{}/{}", self.path, key),
path: self.join_path(key),
});
}
}
@ -53,10 +53,18 @@ impl<'a> ValidationContext<'a> {
if let Some(arr) = self.instance.as_array() {
for i in 0..arr.len() {
if !result.evaluated_indices.contains(&i) {
let mut item_path = self.join_path(&i.to_string());
if let Some(child_instance) = arr.get(i) {
if let Some(obj) = child_instance.as_object() {
if let Some(id_str) = obj.get("id").and_then(|v| v.as_str()) {
item_path = self.join_path(id_str);
}
}
}
result.errors.push(ValidationError {
code: "STRICT_ITEM_VIOLATION".to_string(),
message: format!("Unexpected item at index {}", i),
path: format!("{}/{}", self.path, i),
path: item_path,
});
}
}

View File

@ -14,16 +14,13 @@ impl<'a> ValidationContext<'a> {
let current = self.instance;
if let Some(obj) = current.as_object() {
// Entity implicit type validation
// Use the specific schema id or ref as a fallback
if let Some(identifier) = self.schema.id.as_ref().or(self.schema.r#ref.as_ref()) {
if let Some(schema_identifier) = self.schema.identifier() {
// Kick in if the data object has a type field
if let Some(type_val) = obj.get("type")
&& let Some(type_str) = type_val.as_str()
{
// Get the string or the final segment as the base
let base = identifier.split('.').next_back().unwrap_or("").to_string();
// Check if the base is a global type name
if let Some(type_def) = self.db.types.get(&base) {
// Check if the identifier is a global type name
if let Some(type_def) = self.db.types.get(&schema_identifier) {
// Ensure the instance type is a variation of the global type
if type_def.variations.contains(type_str) {
// Ensure it passes strict mode
@ -35,12 +32,12 @@ impl<'a> ValidationContext<'a> {
"Type '{}' is not a valid descendant for this entity bound schema",
type_str
),
path: format!("{}/type", self.path),
path: self.join_path("type"),
});
}
} else {
// Ad-Hoc schemas natively use strict schema discriminator strings instead of variation inheritance
if type_str == identifier {
if type_str == schema_identifier.as_str() {
result.evaluated_keys.insert("type".to_string());
}
}
@ -73,7 +70,7 @@ impl<'a> ValidationContext<'a> {
result.errors.push(ValidationError {
code: "REQUIRED_FIELD_MISSING".to_string(),
message: format!("Missing {}", field),
path: format!("{}/{}", self.path, field),
path: self.join_path(field),
});
}
}
@ -112,7 +109,7 @@ impl<'a> ValidationContext<'a> {
}
if let Some(child_instance) = obj.get(key) {
let new_path = format!("{}/{}", self.path, key);
let new_path = self.join_path(key);
let is_ref = sub_schema.r#ref.is_some();
let next_extensible = if is_ref { false } else { self.extensible };
@ -128,14 +125,9 @@ impl<'a> ValidationContext<'a> {
// Entity Bound Implicit Type Interception
if key == "type"
&& let Some(schema_bound) = sub_schema.id.as_ref().or(sub_schema.r#ref.as_ref())
&& let Some(schema_bound) = sub_schema.identifier()
{
let physical_type_name = schema_bound
.split('.')
.next_back()
.unwrap_or("")
.to_string();
if let Some(type_def) = self.db.types.get(&physical_type_name)
if let Some(type_def) = self.db.types.get(&schema_bound)
&& let Some(instance_type) = child_instance.as_str()
&& type_def.variations.contains(instance_type)
{
@ -155,7 +147,7 @@ impl<'a> ValidationContext<'a> {
for (compiled_re, sub_schema) in compiled_pp {
for (key, child_instance) in obj {
if compiled_re.0.is_match(key) {
let new_path = format!("{}/{}", self.path, key);
let new_path = self.join_path(key);
let is_ref = sub_schema.r#ref.is_some();
let next_extensible = if is_ref { false } else { self.extensible };
@ -194,7 +186,7 @@ impl<'a> ValidationContext<'a> {
}
if !locally_matched {
let new_path = format!("{}/{}", self.path, key);
let new_path = self.join_path(key);
let is_ref = additional_schema.r#ref.is_some();
let next_extensible = if is_ref { false } else { self.extensible };
@ -215,7 +207,7 @@ impl<'a> ValidationContext<'a> {
if let Some(ref property_names) = self.schema.property_names {
for key in obj.keys() {
let _new_path = format!("{}/propertyNames/{}", self.path, key);
let _new_path = self.join_path(&format!("propertyNames/{}", key));
let val_str = Value::String(key.to_string());
let ctx = ValidationContext::new(

View File

@ -1 +1 @@
1.0.83
1.0.94