Compare commits

..

33 Commits

Author SHA1 Message Date
8b672bd94b version: 1.0.184 2026-07-21 16:29:34 -04:00
441b7e7455 Merge pull request 'immutable-properties' (#1) from immutable-properties into main
Reviewed-on: #1
2026-07-21 20:28:39 +00:00
fbeb2eee22 doc updates 2026-07-21 16:27:37 -04:00
350fe29fef immutable properties complete and tested 2026-07-21 16:25:12 -04:00
ff4b9cd24d dead code cleanup, no-op 2026-07-17 19:18:53 -04:00
68c7c6b77f version: 1.0.183 2026-07-17 18:48:08 -04:00
ea8c5febf2 version: 1.0.182 2026-07-17 18:42:36 -04:00
ea5770aebe added explicit lookup_fields to entity types 2026-07-17 18:42:28 -04:00
e25890461d version: 1.0.181 2026-07-16 13:43:22 -04:00
c4bf6bde58 added limit 1 to arrays treated as a single value in schemas 2026-07-16 13:43:08 -04:00
1fedf5253c test(queryer): update SQL expectations with LIMIT 1 on cover_attachment 2026-07-15 01:52:13 -04:00
9ef59422b8 version: 1.0.180 2026-07-14 16:03:01 -04:00
b49b216b36 fix(queryer): restrict polymorphic bounds compiler to forward edges only
Resolves a query compilation bug in JSPG where reverse/incoming polymorphic
edges (where edge.forward == false, e.g. pet ➡️ cover_attachment) mistakenly
appended the destination type constraint to the parent query's WHERE clause
(e.g., producing `entity_1.type = 'attachment'` on a pet entity query,
causing it to match 0 rows and return null).

- Modifies `compile_polymorphic_bounds` in `compiler.rs` to only compile
  type checks when `edge.forward` is true. For reverse edges, the parent's
  type check does not belong on the parent table and is already implicitly
  restricted by parent ID joins.
- Updates the `fk_attachment_attachable_entity` relation in the queryer test
  fixture to correctly model a two-column polymorphic relation, ensuring
  this code path is exercised by the unit test suite.
2026-07-14 16:02:41 -04:00
13b28387c2 doc update 2026-07-09 20:47:59 -04:00
5b8596f7bd version: 1.0.179 2026-07-09 18:47:25 -04:00
6220a0964d fixed proxy references without null option not being treated as proxies 2026-07-09 18:47:12 -04:00
6cd5bfea7e doc update 2026-07-09 15:26:17 -04:00
a006d3f00a version: 1.0.178 2026-07-09 14:48:03 -04:00
942520a36a added static property constraints using const and enums and fixed formatter ON rendering 2026-07-09 14:47:39 -04:00
1bb9586b9b Merge branch 'main' of gitea-ssh.thoughtpatterns.ai:cellular/jspg 2026-07-09 14:46:03 -04:00
b4882bcb27 generator checkpoint 2026-07-09 14:27:50 -04:00
3bb7eb312a generator checkpoint 2026-07-09 14:26:45 -04:00
73ab6d4ce7 version: 1.0.177 2026-07-09 12:44:55 -04:00
cef65958cb reversed jsonb subquerying for now 2026-07-07 23:10:43 -04:00
95600551a7 version: 1.0.172 2026-07-07 12:45:00 -04:00
c18a8c1561 remove icon from sidebar 2026-07-07 12:44:49 -04:00
8ed588ca61 version: 1.0.171 2026-07-07 12:42:56 -04:00
edd0dd4763 jspg cleanup 2026-07-07 12:42:05 -04:00
abf1d12e43 test: mock matcher understands UNION existence reads; revive dead update-path mocks
parse_and_match_mocks matched mock.type against only the first FROM table —
always 'entity' for hierarchy existence reads — so every merger update-case
mock was silently inert and those cases snapshotted the create path. Now each
UNION arm is matched independently (a mock matching any arm returns once,
mirroring UNION dedup) and the type check accepts any FROM/JOIN table.

Regenerated the five revived cases (their snapshots now show real update /
no-op replace / archive semantics) and added the two previously untestable
probe-disagreement cases: same-row dedup (updates, no error) and id-vs-lookup
conflict (TOO_MANY_LOOKUP_ROWS).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:05:21 -04:00
a885713563 version: 1.0.170 2026-07-06 13:05:16 -04:00
d9b4f417f6 merger existence read: UNION the id and lk-lookup probes instead of OR-ing them across the hierarchy join — the OR is un-indexable and full-scans the subtype table on every child merge (one scan per relationship-edge write; surfaced by Castleberry onboarding); each UNION arm uses its own index (pk / lk_) and dedup preserves TOO_MANY_LOOKUP_ROWS semantics
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:52:29 -04:00
eae39f92d6 version: 1.0.169 2026-07-04 18:27:57 -04:00
2895f06074 page.personal is an opaque filter template (the 'mine' predicate), not a punc name — same read, scope-derived filter
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 18:27:51 -04:00
57 changed files with 1823 additions and 840 deletions

View File

@ -184,6 +184,24 @@ It evaluates as an **Independent Declarative Rules Engine**. Every `Case` block
* **`array`**: Homogeneous collection of items matching the `items` schema. Validation is homogeneous, so strictness checking does not apply. `"extensible"` is not applicable at the `array` level (tuple-like `prefixItems` are removed).
* **Inheritance Boundaries**: Strictness resets when crossing non-primitive `type` boundaries. A schema extending a strict parent remains strict unless it explicitly overrides with `"extensible": true`.
### Immutable Properties (`"immutable": true`)
To distinguish read-only hydrated endpoint references, computed properties, system-managed timestamps (`created_at`, `modified_at`), or audit fields from writable properties, JSPG introduces the universal `"immutable": true` schema keyword.
* **Developer Perspective**: Annotate properties in database schemas or trait definitions with `"immutable": true` when the property should be visible on reads (`jspg_query` / `.response`), but rejected or ignored on writes (`jspg_merge` / `.request`).
```json
"properties": {
"source": {
"family": "lite.organization",
"immutable": true,
"description": "Read-only hydrated member entity summary on a membership edge."
}
}
```
* **Behavior Across Pillars**:
* **Queryer (`jspg_query`)**: Hydrates and includes `immutable` properties in output read responses without restriction.
* **Validator (`jspg_validate`)**: Context-aware. Returns `IMMUTABLE_PROPERTY_VIOLATION` if an `immutable` property is supplied in a write/request payload (schema IDs not ending in `.response`). Response schemas (`.response`) permit `immutable` fields.
* **Merger (`jspg_merge`)**: Automatically skips `immutable` properties during object graph merging so client payloads can never mutate database columns or relationship edges.
### Format Leniency for Empty Strings
To simplify frontend form validation, format validators specifically for `uuid`, `date-time`, and `email` explicitly allow empty strings (`""`), treating them as "present but unset".
@ -229,6 +247,40 @@ Traits are reusable, non-generating schema fragments used to share properties an
* **Scalars / Arrays / Items**: Host definitions completely override included traits.
* The `"include"` keyword is stripped, and `"traits"` maps are omitted from serialization.
### Static Relation Constraints (Kind Constraints)
When modeling relational properties on a schema, a developer can define a specialized subset of a related table by applying static property constraints via the `const` or `enum` validation keywords.
For example, given a general `attachment` table containing a `kind` column (e.g. `'cover'`, `'thumbnail'`, `'document'`), you can define a `cover.attachment` schema that narrows the type using a static `const` assertion:
```json
"cover.attachment": {
"type": "attachment",
"properties": {
"kind": {
"type": "string",
"const": "cover"
}
}
}
```
A parent entity can then define a relationship using this constrained schema under a local property name (e.g. `cover_attachment`):
```json
"cover_attachment": {
"properties": {
"cover_attachment": {
"type": "cover.attachment"
}
}
}
```
**What it does:**
1. **Validation (L1)**: During payload validation (`jspg_validate`), any incoming object mapped to the constrained property is validated against the static rules (e.g. throwing `CONST_VIOLATED` if `kind` is not `"cover"`).
2. **Query Generation (L0)**: When fetching data via `jspg_query`, the Queryer automatically detects the static constraint and compiles it into the SQL subquery's `WHERE` clause (e.g. adding `AND attachment_X.kind = 'cover'`). This produces a pre-filtered view of the related entities natively at the database level.
---
## 3. Database
@ -268,6 +320,7 @@ JSPG implements specific extensions to the Draft 2020-12 standard to support the
* **Missing Type Ultimatum**: If an entity logically requires a discriminator and the JSON payload omits it, JSPG short-circuits branch execution entirely, bubbling a single, perfectly-pathed `MISSING_TYPE` error back to the UI natively to prevent confusing cascading failures.
* **Golden Match Context**: When exactly one structural candidate perfectly maps a discriminator, the Validator exclusively cascades that specific structural error context directly to the user, stripping away all noise generated by other parallel schemas.
* **Topological Array Pathing**: Instead of relying on explicit `$id` references or injected properties, array iteration paths are dynamically typed based on their compiler boundary constraints. If the array's `items` schema resolves to a topological table-backed entity (e.g., inheriting via a `family` macro tracked in the global DB catalog), the array locks paths and derives element indexes from their actual UUID paths (`array/widget-1/name`), natively enforcing database continuity. If evaluating isolated ad-hoc JSONB elements, strict numeric indexing is enforced natively (`array/1/name`) preventing synthetic payload manipulation.
* **Context-Aware Immutability Validation**: `jspg_validate` evaluates the target schema ID context. If the target schema ID does not end with `.response` (inbound write/request context), any property present in the payload marked with `"immutable": true` raises an `IMMUTABLE_PROPERTY_VIOLATION` error. Response schemas (`.response`) permit `immutable` fields for read hydration.
---
@ -280,13 +333,16 @@ The Merger provides an automated, high-performance graph synchronization engine.
### Core Features
* **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.
* **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 (`lookup_fields`) 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.
* **Immutable Property Filtering**: Properties declaring `"immutable": true` are filtered out during schema property traversal prior to edge classification or column assembly, guaranteeing client payloads cannot write or mutate read-only/computed properties or hydrated endpoint references.
* **Factual Creation Dependency Validation**: When `stage_entity` confirms `kind == "create"` via database lookup, `jspg_merge` evaluates `schema.obj.dependencies.get("created")`. If required creation fields are missing from `entity_fields`, `entity_objects`, or `entity_arrays`, `jspg_merge` halts and returns a structured `REQUIRED_FIELD_MISSING` `Drop`.
* **Structured Fail-Fast Error Propagation**: Internal merger operations return typed `Result<T, crate::drop::Error>` instances, allowing clean fail-fast `?` traversal and returning `Drop::with_errors(vec![err])` directly at the top-level API boundary on failure.
* **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.
* **Dynamic Deduplication & Lookups**: If a nested object is provided without an `id`, the Merger utilizes custom `lookup_fields` declared directly in the schema registry JSON comments. It validates at setup compile-time that a corresponding unique index exists in PostgreSQL for these fields. When merging, it dynamically builds query predicates for any satisfied `lookup_fields` sets in the entity's type hierarchy (checking child-to-parent hierarchies order-independently and combining satisfied keys with `UNION` queries) to discover the correct UUID to perform an UPDATE, preventing data duplication.
* **Hierarchical Table Inheritance**: The Punc system uses distributed table inheritance (e.g. `person` inherits `user` inherits `organization` inherits `entity`). The Merger splits the incoming JSON payload and performs atomic row updates across *all* relevant tables in the lineage map.
* **The Archive Paradigm**: Data is never deleted in the Punc system. The Merger securely enforces referential integrity by toggling the `archived` Boolean flag on the base `entity` table rather than issuing SQL `DELETE` commands.
* **Change Tracking & Reactivity**: The Merger diffs the incoming JSON against the existing database row (utilizing static, `DashMap`-cached `lk_` SELECT string templates). Every detected change is recorded into the `agreego.change` audit table, tracking the user mapping. It then natively uses `pg_notify` to broadcast a completely flat row-level diff out to the Go WebSocket server for O(1) routing.
* **Change Tracking & Reactivity**: The Merger diffs the incoming JSON against the existing database row (utilizing static, `DashMap`-cached `lookup` SELECT string templates). Every detected change is recorded into the `agreego.change` audit table, tracking the user mapping. It then natively uses `pg_notify` to broadcast a completely flat row-level diff out to the Go WebSocket server for O(1) routing.
* **Flat Structural Beats (Unidirectional Flow)**: The Merger purposefully DOES NOT trace or hydrate outbound Foreign Keys or nested parent structures during writes. It emits completely flat, mathematically perfect structural deltas via `pg_notify` representing only the exact Postgres rows that changed. This guarantees the write-path remains O(1) lightning fast. It is the strict responsibility of the upstream Punc Framework (the Go `Speaker`) to intercept these flat beats, evaluate them against active Websocket Schema Topologies, and dynamically issue targeted `jspg_query` reads to hydrate the exact contextual subgraphs required by listening clients.
* **Pre-Order Notification Traversal**: To support proper topological hydration on the upstream Go Framework, the Merger decouples the `pg_notify` execution from the physical database write execution. The engine collects structural changes and explicitly fires `pg_notify` SQL statements in strict **Pre-Order** (Parent -> Relations -> Children). This guarantees that WebSocket clients receive the parent entity `Beat` prior to any nested child entities, ensuring stable unidirectional data flows without hydration race conditions.
* **Many-to-Many Graph Edge Management**: Operates seamlessly with the global `agreego.relationship` table, allowing the system to represent and merge arbitrary reified M:M relationships directionally between any two entities.
@ -314,6 +370,8 @@ The Queryer transforms Postgres into a pre-compiled Semantic Query Engine, desig
* **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 sub-queries for each variation. To ensure safe resolution, the compiler dynamically evaluates correlation boundaries: it attempts standard Relational Edge discovery first. If no explicit relational edge exists (indicating pure Table Inheritance rather than a standard foreign-key graph relationship), it safely invokes a **Table Parity Fallback**. This generates an explicit ID correlation constraint (`AND inner.id = outer.id`), perfectly binding the structural variations back to the parent row to eliminate Cartesian products.
* **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.
* **Polymorphic Relation Type Filtering**: When a relationship maps to a polymorphic target with variations, the Queryer compiles an `IN` clause containing all allowed table variations (e.g., `counterparty_type IN ('bot', 'organization', 'person')`) rather than matching the base type literal, ensuring all polymorphic types are loaded correctly.
* **Static Relation Constraints (Kind Constraints)**: When a relationship (such as a nested object or array) is defined with a schema that constrains a field value statically using a `const` or `enum` keyword (for example, `kind` constrained to `"cover"` in a `cover_attachment`), the Queryer automatically extracts these static assertions during AST compilation. It injects them directly as static filters into the SQL subquery's `WHERE` clause (e.g. `AND attachment.kind = 'cover'`), allowing developers to query pre-filtered subsets of related tables natively through the schema.
* **Proxy Schema Dereferencing / Resolution**: To support punc endpoints that return non-polymorphic table-backed shapes (using `type: "full.X"` proxy schemas at the root response level), the Queryer compiler automatically dereferences non-table schema pointers to their target schemas prior to checking the types. This allows the Queryer to correctly resolve the table relationship edges pre-compiled on the full schema, while avoiding polluting the database registry with relations on ad-hoc punc response schemas during setup.
---

View File

@ -72,8 +72,8 @@
{
"code": "CONTAINS_VIOLATED",
"values": {
"limit": "1",
"count": "0"
"count": "0",
"limit": "1"
},
"details": {
"path": "",
@ -177,8 +177,8 @@
{
"code": "CONTAINS_VIOLATED",
"values": {
"limit": "1",
"count": "0"
"count": "0",
"limit": "1"
},
"details": {
"path": "",
@ -347,8 +347,8 @@
{
"code": "CONTAINS_VIOLATED",
"values": {
"limit": "1",
"count": "0"
"count": "0",
"limit": "1"
},
"details": {
"path": "",
@ -373,8 +373,8 @@
{
"code": "MULTIPLE_OF_VIOLATED",
"values": {
"multiple_of": "2",
"value": "3"
"value": "3",
"multiple_of": "2"
},
"details": {
"path": "0",
@ -432,8 +432,8 @@
{
"code": "MULTIPLE_OF_VIOLATED",
"values": {
"multiple_of": "2",
"value": "1"
"value": "1",
"multiple_of": "2"
},
"details": {
"path": "0",
@ -443,8 +443,8 @@
{
"code": "MULTIPLE_OF_VIOLATED",
"values": {
"multiple_of": "2",
"value": "5"
"value": "5",
"multiple_of": "2"
},
"details": {
"path": "1",

View File

@ -151,9 +151,9 @@
{
"code": "EDGE_MISSING",
"values": {
"property_name": "children",
"parent_type": "parent",
"child_type": "child"
"child_type": "child",
"property_name": "children"
},
"details": {
"path": "full.parent/children",
@ -254,9 +254,9 @@
{
"code": "AMBIGUOUS_TYPE_RELATIONS",
"values": {
"parent_type": "invoice",
"child_type": "activity",
"property_name": "activities",
"parent_type": "invoice"
"property_name": "activities"
},
"details": {
"path": "full.invoice/activities",
@ -390,9 +390,9 @@
{
"code": "AMBIGUOUS_TYPE_RELATIONS",
"values": {
"property_name": "ambiguous_edge",
"parent_type": "actor",
"child_type": "junction",
"parent_type": "actor"
"property_name": "ambiguous_edge"
},
"details": {
"path": "full.actor/ambiguous_edge",
@ -911,9 +911,7 @@
"archived"
]
},
"lookup_fields": [
"id"
],
"lookup_fields": [],
"historical": true,
"relationship": false,
"field_types": {
@ -1018,5 +1016,100 @@
}
}
]
},
{
"description": "Validation - lookup fields without matching index",
"database": {
"types": [
{
"id": "22222222-2222-2222-2222-222222222222",
"type": "type",
"name": "user",
"module": "test",
"source": "test",
"hierarchy": ["user"],
"variations": ["user"],
"fields": ["id", "email"],
"lookup_fields": ["email"],
"schemas": {
"user": {
"type": "object",
"properties": {
"id": { "type": "string", "format": "uuid" },
"email": { "type": "string", "format": "email" }
}
}
}
}
]
},
"tests": [
{
"description": "Fails setup compilation with MISSING_LOOKUP_INDEX if unique index is missing",
"action": "compile",
"expect": {
"success": false,
"errors": [
{
"code": "MISSING_LOOKUP_INDEX",
"values": {
"type": "user",
"lookup_fields": "[\"email\"]"
},
"details": {
"path": "/types/user",
"schema": "user"
}
}
]
}
}
]
},
{
"description": "Validation - lookup fields with matching index",
"database": {
"indexes": [
{
"table": "user",
"columns": ["email"]
}
],
"types": [
{
"id": "22222222-2222-2222-2222-222222222222",
"type": "type",
"name": "user",
"module": "test",
"source": "test",
"hierarchy": ["user"],
"variations": ["user"],
"fields": ["id", "email"],
"lookup_fields": ["email"],
"schemas": {
"user": {
"type": "object",
"properties": {
"id": { "type": "string", "format": "uuid" },
"email": { "type": "string", "format": "email" }
}
}
}
}
]
},
"tests": [
{
"description": "Compiles successfully when unique index matches lookup_fields",
"action": "compile",
"expect": {
"success": true,
"schemas": {
"user": {},
"user.filter": {}
}
}
}
]
}
]

View File

@ -65,8 +65,8 @@
{
"code": "DEPENDENCY_MISSING",
"values": {
"required_property": "foo",
"property_name": "bar"
"property_name": "bar",
"required_property": "foo"
},
"details": {
"path": "",
@ -227,8 +227,8 @@
{
"code": "DEPENDENCY_MISSING",
"values": {
"required_property": "bar",
"property_name": "quux"
"property_name": "quux",
"required_property": "bar"
},
"details": {
"path": "",
@ -276,8 +276,8 @@
{
"code": "DEPENDENCY_MISSING",
"values": {
"required_property": "foo",
"property_name": "quux"
"property_name": "quux",
"required_property": "foo"
},
"details": {
"path": "",
@ -287,8 +287,8 @@
{
"code": "DEPENDENCY_MISSING",
"values": {
"required_property": "bar",
"property_name": "quux"
"property_name": "quux",
"required_property": "bar"
},
"details": {
"path": "",
@ -362,8 +362,8 @@
{
"code": "DEPENDENCY_MISSING",
"values": {
"required_property": "foo\rbar",
"property_name": "foo\nbar"
"property_name": "foo\nbar",
"required_property": "foo\rbar"
},
"details": {
"path": "",
@ -386,8 +386,8 @@
{
"code": "DEPENDENCY_MISSING",
"values": {
"required_property": "foo'bar",
"property_name": "foo\"bar"
"property_name": "foo\"bar",
"required_property": "foo'bar"
},
"details": {
"path": "",

View File

@ -128,8 +128,8 @@
{
"code": "PATTERN_VIOLATED",
"values": {
"value": "abcd",
"pattern": "^[a-z]{3}$"
"pattern": "^[a-z]{3}$",
"value": "abcd"
},
"details": {
"path": "keys/abcd",

View File

@ -34,8 +34,8 @@
{
"code": "EXCLUSIVE_MAXIMUM_VIOLATED",
"values": {
"limit": "3",
"value": "3"
"value": "3",
"limit": "3"
},
"details": {
"path": "",
@ -56,8 +56,8 @@
{
"code": "EXCLUSIVE_MAXIMUM_VIOLATED",
"values": {
"limit": "3",
"value": "3.5"
"value": "3.5",
"limit": "3"
},
"details": {
"path": "",

View File

@ -87,34 +87,6 @@
"type": "string"
}
}
},
"schedule": {
"type": [
"opening_hours",
"null"
]
}
}
},
"season": {
"type": "object",
"properties": {
"label": {
"type": "string"
}
}
},
"opening_hours": {
"type": "object",
"properties": {
"open": {
"type": "string"
},
"seasons": {
"type": "array",
"items": {
"type": "season"
}
}
}
}
@ -239,13 +211,6 @@
"gender": {},
"gender.condition": {
"type": "condition",
"compiledPropertyNames": [
"kind",
"$eq",
"$ne",
"$of",
"$nof"
],
"properties": {
"$eq": {
"type": [
@ -259,15 +224,6 @@
"null"
]
},
"$nof": {
"type": [
"array",
"null"
],
"items": {
"type": "gender"
}
},
"$of": {
"type": [
"array",
@ -276,83 +232,32 @@
"items": {
"type": "gender"
}
},
"$nof": {
"type": [
"array",
"null"
],
"items": {
"type": "gender"
}
}
}
},
"compiledPropertyNames": [
"kind",
"$eq",
"$ne",
"$of",
"$nof"
]
},
"person": {},
"person.filter": {
"compiledPropertyNames": [
"first_name",
"age",
"billing_address",
"gender",
"birth_date",
"uuid_field",
"tags",
"ad_hoc",
"schedule",
"$and",
"$or"
],
"type": "filter",
"properties": {
"$and": {
"items": {
"compiledPropertyNames": [
"first_name",
"age",
"billing_address",
"gender",
"birth_date",
"uuid_field",
"tags",
"ad_hoc",
"schedule",
"$and",
"$or"
],
"type": "person.filter"
},
"first_name": {
"type": [
"array",
"null"
]
},
"$or": {
"items": {
"compiledPropertyNames": [
"first_name",
"age",
"billing_address",
"gender",
"birth_date",
"uuid_field",
"tags",
"ad_hoc",
"schedule",
"$and",
"$or"
],
"type": "person.filter"
},
"type": [
"array",
"null"
]
},
"ad_hoc": {
"compiledPropertyNames": [
"foo"
],
"properties": {
"foo": {
"type": [
"string.condition",
"null"
]
}
},
"type": [
"filter",
"string.condition",
"null"
]
},
@ -368,6 +273,12 @@
"null"
]
},
"gender": {
"type": [
"gender.condition",
"null"
]
},
"birth_date": {
"type": [
"date.condition",
@ -380,54 +291,48 @@
"null"
]
},
"first_name": {
"type": [
"string.condition",
"null"
]
},
"gender": {
"type": [
"gender.condition",
"null"
]
},
"tags": {
"type": [
"string.condition",
"null"
]
},
"schedule": {
"ad_hoc": {
"type": [
"opening_hours.filter",
"filter",
"null"
],
"properties": {
"foo": {
"type": [
"string.condition",
"null"
]
}
},
"compiledPropertyNames": [
"foo"
]
}
},
"type": "filter"
},
"address": {},
"address.filter": {
"type": "filter",
"compiledPropertyNames": [
"city",
"$and",
"$or"
],
"properties": {
},
"$and": {
"type": [
"array",
"null"
],
"items": {
"type": "person.filter",
"compiledPropertyNames": [
"city",
"first_name",
"age",
"billing_address",
"gender",
"birth_date",
"uuid_field",
"tags",
"ad_hoc",
"$and",
"$or"
],
"type": "address.filter"
]
}
},
"$or": {
@ -436,21 +341,79 @@
"null"
],
"items": {
"type": "person.filter",
"compiledPropertyNames": [
"city",
"first_name",
"age",
"billing_address",
"gender",
"birth_date",
"uuid_field",
"tags",
"ad_hoc",
"$and",
"$or"
],
"type": "address.filter"
]
}
},
}
},
"compiledPropertyNames": [
"first_name",
"age",
"billing_address",
"gender",
"birth_date",
"uuid_field",
"tags",
"ad_hoc",
"$and",
"$or"
]
},
"address": {},
"address.filter": {
"type": "filter",
"properties": {
"city": {
"type": [
"string.condition",
"null"
]
},
"$and": {
"type": [
"array",
"null"
],
"items": {
"type": "address.filter",
"compiledPropertyNames": [
"city",
"$and",
"$or"
]
}
},
"$or": {
"type": [
"array",
"null"
],
"items": {
"type": "address.filter",
"compiledPropertyNames": [
"city",
"$and",
"$or"
]
}
}
}
},
"compiledPropertyNames": [
"city",
"$and",
"$or"
]
},
"condition": {},
"filter": {},
@ -461,52 +424,7 @@
"search": {},
"search.filter": {
"type": "filter",
"compiledPropertyNames": [
"kind",
"name",
"filter",
"$and",
"$or"
],
"properties": {
"$and": {
"type": [
"array",
"null"
],
"items": {
"compiledPropertyNames": [
"kind",
"name",
"filter",
"$and",
"$or"
],
"type": "search.filter"
}
},
"$or": {
"type": [
"array",
"null"
],
"items": {
"compiledPropertyNames": [
"kind",
"name",
"filter",
"$and",
"$or"
],
"type": "search.filter"
}
},
"filter": {
"type": [
"$kind.filter",
"null"
]
},
"kind": {
"type": [
"string.condition",
@ -518,29 +436,10 @@
"string.condition",
"null"
]
}
}
},
"opening_hours": {},
"season": {},
"opening_hours.filter": {
"type": "filter",
"compiledPropertyNames": [
"open",
"seasons",
"$and",
"$or"
],
"properties": {
"open": {
"type": [
"string.condition",
"null"
]
},
"seasons": {
"filter": {
"type": [
"season.filter",
"$kind.filter",
"null"
]
},
@ -550,10 +449,11 @@
"null"
],
"items": {
"type": "opening_hours.filter",
"type": "search.filter",
"compiledPropertyNames": [
"open",
"seasons",
"kind",
"name",
"filter",
"$and",
"$or"
]
@ -565,21 +465,28 @@
"null"
],
"items": {
"type": "opening_hours.filter",
"type": "search.filter",
"compiledPropertyNames": [
"open",
"seasons",
"kind",
"name",
"filter",
"$and",
"$or"
]
}
}
}
},
"season.filter": {}
},
"compiledPropertyNames": [
"kind",
"name",
"filter",
"$and",
"$or"
]
}
}
}
}
]
}
]
]

File diff suppressed because it is too large Load Diff

View File

@ -70,8 +70,8 @@
{
"code": "CONTAINS_VIOLATED",
"values": {
"limit": "1",
"count": "0"
"count": "0",
"limit": "1"
},
"details": {
"path": "",
@ -106,8 +106,8 @@
{
"code": "CONTAINS_VIOLATED",
"values": {
"limit": "1",
"count": "2"
"count": "2",
"limit": "1"
},
"details": {
"path": "",
@ -284,8 +284,8 @@
{
"code": "CONTAINS_VIOLATED",
"values": {
"limit": "3",
"count": "4"
"count": "4",
"limit": "3"
},
"details": {
"path": "",

View File

@ -52,8 +52,8 @@
{
"code": "MAX_ITEMS_VIOLATED",
"values": {
"limit": "2",
"count": "3"
"count": "3",
"limit": "2"
},
"details": {
"path": "",

View File

@ -234,8 +234,8 @@
{
"code": "MAX_PROPERTIES_VIOLATED",
"values": {
"limit": "2",
"count": "3"
"count": "3",
"limit": "2"
},
"details": {
"path": "",

View File

@ -43,8 +43,8 @@
{
"code": "MAXIMUM_VIOLATED",
"values": {
"limit": "3",
"value": "3.5"
"value": "3.5",
"limit": "3"
},
"details": {
"path": "",

View File

@ -239,8 +239,8 @@
{
"code": "DEPENDENCY_MISSING",
"values": {
"required_property": "base_dep",
"property_name": "trigger"
"property_name": "trigger",
"required_property": "base_dep"
},
"details": {
"path": "",
@ -264,8 +264,8 @@
{
"code": "DEPENDENCY_MISSING",
"values": {
"required_property": "child_dep",
"property_name": "trigger"
"property_name": "trigger",
"required_property": "child_dep"
},
"details": {
"path": "",

View File

@ -117,6 +117,16 @@
]
}
],
"indexes": [
{
"table": "person",
"columns": ["first_name", "last_name", "date_of_birth", "pronouns"]
},
{
"table": "user",
"columns": ["name"]
}
],
"types": [
{
"name": "entity",
@ -296,7 +306,9 @@
"archived"
]
},
"lookup_fields": [],
"lookup_fields": [
"name"
],
"historical": true,
"notify": true,
"relationship": false
@ -478,9 +490,7 @@
"organization_id"
]
},
"lookup_fields": [
"id"
],
"lookup_fields": [],
"historical": true,
"notify": true,
"relationship": false
@ -1068,9 +1078,7 @@
"archived"
]
},
"lookup_fields": [
"id"
],
"lookup_fields": [],
"historical": true,
"relationship": false,
"field_types": {
@ -1310,9 +1318,9 @@
[
"(SELECT to_jsonb(t1.*) || to_jsonb(t2.*) || to_jsonb(t3.*) || to_jsonb(t4.*)",
"FROM agreego.\"entity\" t1",
"JOIN agreego.\"organization\" t2 ON ",
"JOIN agreego.\"user\" t3 ON ",
"JOIN agreego.\"person\" t4 ON ",
"LEFT JOIN agreego.\"organization\" t2 ON t2.id = t1.id",
"LEFT JOIN agreego.\"user\" t3 ON t3.id = t1.id",
"LEFT JOIN agreego.\"person\" t4 ON t4.id = t1.id",
"WHERE",
" (\"first_name\" = 'LookupFirst'",
" AND \"last_name\" = 'LookupLast'",
@ -1463,12 +1471,18 @@
[
"(SELECT to_jsonb(t1.*) || to_jsonb(t2.*) || to_jsonb(t3.*) || to_jsonb(t4.*)",
"FROM agreego.\"entity\" t1",
"JOIN agreego.\"organization\" t2 ON ",
"JOIN agreego.\"user\" t3 ON ",
"JOIN agreego.\"person\" t4 ON ",
"LEFT JOIN agreego.\"organization\" t2 ON t2.id = t1.id",
"LEFT JOIN agreego.\"user\" t3 ON t3.id = t1.id",
"LEFT JOIN agreego.\"person\" t4 ON t4.id = t1.id",
"WHERE",
" t1.id = '{{uuid:data.id}}'",
" OR (\"first_name\" = 'LookupFirst'",
"UNION SELECT to_jsonb(t1.*) || to_jsonb(t2.*) || to_jsonb(t3.*) || to_jsonb(t4.*)",
"FROM agreego.\"entity\" t1",
"LEFT JOIN agreego.\"organization\" t2 ON t2.id = t1.id",
"LEFT JOIN agreego.\"user\" t3 ON t3.id = t1.id",
"LEFT JOIN agreego.\"person\" t4 ON t4.id = t1.id",
"WHERE",
" (\"first_name\" = 'LookupFirst'",
" AND \"last_name\" = 'LookupLast'",
" AND \"date_of_birth\" = '{{timestamp}}'",
" AND \"pronouns\" = 'they/them'))"
@ -1587,6 +1601,180 @@
]
}
},
{
"description": "Update existing person with id and multiple inherited lookup keys",
"action": "merge",
"data": {
"id": "33333333-3333-3333-3333-333333333333",
"type": "person",
"name": "LookupName",
"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",
"name": "LookupName",
"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.\"entity\" t1",
"LEFT JOIN agreego.\"organization\" t2 ON t2.id = t1.id",
"LEFT JOIN agreego.\"user\" t3 ON t3.id = t1.id",
"LEFT JOIN agreego.\"person\" t4 ON t4.id = t1.id",
"WHERE",
" t1.id = '{{uuid:data.id}}'",
"UNION SELECT to_jsonb(t1.*) || to_jsonb(t2.*) || to_jsonb(t3.*) || to_jsonb(t4.*)",
"FROM agreego.\"entity\" t1",
"LEFT JOIN agreego.\"organization\" t2 ON t2.id = t1.id",
"LEFT JOIN agreego.\"user\" t3 ON t3.id = t1.id",
"LEFT JOIN agreego.\"person\" t4 ON t4.id = t1.id",
"WHERE",
" (\"first_name\" = 'LookupFirst'",
" AND \"last_name\" = 'LookupLast'",
" AND \"date_of_birth\" = '{{timestamp}}'",
" AND \"pronouns\" = 'they/them')",
"UNION SELECT to_jsonb(t1.*) || to_jsonb(t2.*) || to_jsonb(t3.*) || to_jsonb(t4.*)",
"FROM agreego.\"entity\" t1",
"LEFT JOIN agreego.\"organization\" t2 ON t2.id = t1.id",
"LEFT JOIN agreego.\"user\" t3 ON t3.id = t1.id",
"LEFT JOIN agreego.\"person\" t4 ON t4.id = t1.id",
"WHERE",
" (\"name\" = 'LookupName'))"
],
[
"INSERT INTO agreego.\"entity\" (",
" \"created_at\",",
" \"created_by\",",
" \"id\",",
" \"modified_at\",",
" \"modified_by\",",
" \"type\"",
")",
"VALUES (",
" '{{timestamp}}',",
" '00000000-0000-0000-0000-000000000000',",
" '{{uuid:data.id}}',",
" '{{timestamp}}',",
" '00000000-0000-0000-0000-000000000000',",
" 'person'",
")"
],
[
"INSERT INTO agreego.\"organization\" (",
" \"id\",",
" \"name\",",
" \"type\"",
")",
"VALUES (",
" '{{uuid:data.id}}',",
" 'LookupName',",
" 'person'",
")"
],
[
"INSERT INTO agreego.\"user\" (",
" \"id\",",
" \"type\"",
")",
"VALUES (",
" '{{uuid:data.id}}',",
" 'person'",
")"
],
[
"INSERT INTO agreego.\"person\" (",
" \"contact_id\",",
" \"date_of_birth\",",
" \"first_name\",",
" \"id\",",
" \"last_name\",",
" \"pronouns\",",
" \"type\"",
")",
"VALUES (",
" 'abc-contact',",
" '{{timestamp}}',",
" 'LookupFirst',",
" '{{uuid:data.id}}',",
" 'LookupLast',",
" 'they/them',",
" 'person'",
")"
],
[
"INSERT INTO agreego.change (",
" \"old\",",
" \"new\",",
" \"entity_id\",",
" \"id\",",
" \"kind\",",
" \"modified_at\",",
" \"modified_by\"",
")",
"VALUES (",
" NULL,",
" '{",
" \"name\": \"LookupName\",",
" \"first_name\": \"LookupFirst\",",
" \"last_name\": \"LookupLast\",",
" \"date_of_birth\": \"{{timestamp}}\",",
" \"pronouns\": \"they/them\",",
" \"contact_id\": \"abc-contact\",",
" \"type\": \"person\"",
" }',",
" '{{uuid:data.id}}',",
" '{{uuid:generated_0}}',",
" 'create',",
" '{{timestamp}}',",
" '00000000-0000-0000-0000-000000000000'",
")"
],
[
"(SELECT pg_notify('entity', '{",
" \"kind\": \"create\",",
" \"complete\": {",
" \"name\": \"LookupName\",",
" \"first_name\": \"LookupFirst\",",
" \"last_name\": \"LookupLast\",",
" \"date_of_birth\": \"{{timestamp}}\",",
" \"pronouns\": \"they/them\",",
" \"contact_id\": \"abc-contact\",",
" \"id\": \"{{uuid:data.id}}\",",
" \"type\": \"person\",",
" \"created_by\": \"00000000-0000-0000-0000-000000000000\",",
" \"created_at\": \"{{timestamp}}\",",
" \"modified_by\": \"00000000-0000-0000-0000-000000000000\",",
" \"modified_at\": \"{{timestamp}}\"",
" },",
" \"new\": {",
" \"name\": \"LookupName\",",
" \"first_name\": \"LookupFirst\",",
" \"last_name\": \"LookupLast\",",
" \"date_of_birth\": \"{{timestamp}}\",",
" \"pronouns\": \"they/them\",",
" \"contact_id\": \"abc-contact\",",
" \"type\": \"person\"",
" }",
"}'))"
]
]
}
},
{
"description": "Replace existing person with id and no changes (lookup)",
"action": "merge",
@ -1616,12 +1804,18 @@
[
"(SELECT to_jsonb(t1.*) || to_jsonb(t2.*) || to_jsonb(t3.*) || to_jsonb(t4.*)",
"FROM agreego.\"entity\" t1",
"JOIN agreego.\"organization\" t2 ON ",
"JOIN agreego.\"user\" t3 ON ",
"JOIN agreego.\"person\" t4 ON ",
"LEFT JOIN agreego.\"organization\" t2 ON t2.id = t1.id",
"LEFT JOIN agreego.\"user\" t3 ON t3.id = t1.id",
"LEFT JOIN agreego.\"person\" t4 ON t4.id = t1.id",
"WHERE",
" t1.id = '{{uuid:data.id}}'",
" OR (\"first_name\" = 'LookupFirst'",
"UNION SELECT to_jsonb(t1.*) || to_jsonb(t2.*) || to_jsonb(t3.*) || to_jsonb(t4.*)",
"FROM agreego.\"entity\" t1",
"LEFT JOIN agreego.\"organization\" t2 ON t2.id = t1.id",
"LEFT JOIN agreego.\"user\" t3 ON t3.id = t1.id",
"LEFT JOIN agreego.\"person\" t4 ON t4.id = t1.id",
"WHERE",
" (\"first_name\" = 'LookupFirst'",
" AND \"last_name\" = 'LookupLast'",
" AND \"date_of_birth\" = '{{timestamp}}'",
" AND \"pronouns\" = 'they/them'))"
@ -1759,9 +1953,9 @@
[
"(SELECT to_jsonb(t1.*) || to_jsonb(t2.*) || to_jsonb(t3.*) || to_jsonb(t4.*)",
"FROM agreego.\"entity\" t1",
"JOIN agreego.\"organization\" t2 ON ",
"JOIN agreego.\"user\" t3 ON ",
"JOIN agreego.\"person\" t4 ON ",
"LEFT JOIN agreego.\"organization\" t2 ON t2.id = t1.id",
"LEFT JOIN agreego.\"user\" t3 ON t3.id = t1.id",
"LEFT JOIN agreego.\"person\" t4 ON t4.id = t1.id",
"WHERE",
" t1.id = '{{uuid:mocks.0.id}}')"
],
@ -1882,9 +2076,9 @@
[
"(SELECT to_jsonb(t1.*) || to_jsonb(t2.*) || to_jsonb(t3.*) || to_jsonb(t4.*)",
"FROM agreego.\"entity\" t1",
"JOIN agreego.\"organization\" t2 ON ",
"JOIN agreego.\"user\" t3 ON ",
"JOIN agreego.\"person\" t4 ON ",
"LEFT JOIN agreego.\"organization\" t2 ON t2.id = t1.id",
"LEFT JOIN agreego.\"user\" t3 ON t3.id = t1.id",
"LEFT JOIN agreego.\"person\" t4 ON t4.id = t1.id",
"WHERE",
" t1.id = '123')"
],
@ -2221,10 +2415,9 @@
[
"(SELECT to_jsonb(t1.*) || to_jsonb(t2.*)",
"FROM agreego.\"entity\" t1",
"JOIN agreego.\"order\" t2 ON ",
"LEFT JOIN agreego.\"order\" t2 ON t2.id = t1.id",
"WHERE",
" t1.id = 'abc'",
" OR (\"id\" = 'abc'))"
" t1.id = 'abc')"
],
[
"INSERT INTO agreego.\"entity\" (",
@ -3024,9 +3217,9 @@
[
"(SELECT to_jsonb(t1.*) || to_jsonb(t2.*) || to_jsonb(t3.*) || to_jsonb(t4.*)",
"FROM agreego.\"entity\" t1",
"JOIN agreego.\"organization\" t2 ON ",
"JOIN agreego.\"user\" t3 ON ",
"JOIN agreego.\"person\" t4 ON ",
"LEFT JOIN agreego.\"organization\" t2 ON t2.id = t1.id",
"LEFT JOIN agreego.\"user\" t3 ON t3.id = t1.id",
"LEFT JOIN agreego.\"person\" t4 ON t4.id = t1.id",
"WHERE",
" t1.id = 'abc-archived')"
],
@ -3377,7 +3570,7 @@
[
"(SELECT to_jsonb(t1.*) || to_jsonb(t2.*)",
"FROM agreego.\"entity\" t1",
"JOIN agreego.\"order_line\" t2 ON ",
"LEFT JOIN agreego.\"order_line\" t2 ON t2.id = t1.id",
"WHERE",
" t1.id = '{{uuid:data.lines.0.id}}')"
],
@ -3501,10 +3694,9 @@
[
"(SELECT to_jsonb(t1.*) || to_jsonb(t2.*)",
"FROM agreego.\"entity\" t1",
"JOIN agreego.\"invoice\" t2 ON ",
"LEFT JOIN agreego.\"invoice\" t2 ON t2.id = t1.id",
"WHERE",
" t1.id = '{{uuid:data.id}}'",
" OR (\"id\" = '{{uuid:data.id}}'))"
" t1.id = '{{uuid:data.id}}')"
],
[
"INSERT INTO agreego.\"entity\" (",
@ -3618,7 +3810,7 @@
[
"(SELECT to_jsonb(t1.*) || to_jsonb(t2.*)",
"FROM agreego.\"entity\" t1",
"JOIN agreego.\"account\" t2 ON ",
"LEFT JOIN agreego.\"account\" t2 ON t2.id = t1.id",
"WHERE",
" t1.id = '{{uuid:data.id}}')"
],

View File

@ -67,8 +67,8 @@
{
"code": "CONTAINS_VIOLATED",
"values": {
"limit": "1",
"count": "0"
"count": "0",
"limit": "1"
},
"details": {
"path": "",
@ -91,8 +91,8 @@
{
"code": "CONTAINS_VIOLATED",
"values": {
"limit": "1",
"count": "0"
"count": "0",
"limit": "1"
},
"details": {
"path": "",
@ -169,8 +169,8 @@
{
"code": "CONTAINS_VIOLATED",
"values": {
"limit": "2",
"count": "0"
"count": "0",
"limit": "2"
},
"details": {
"path": "",
@ -218,8 +218,8 @@
{
"code": "CONTAINS_VIOLATED",
"values": {
"limit": "2",
"count": "1"
"count": "1",
"limit": "2"
},
"details": {
"path": "",
@ -381,8 +381,8 @@
{
"code": "CONTAINS_VIOLATED",
"values": {
"limit": "2",
"count": "1"
"count": "1",
"limit": "2"
},
"details": {
"path": "",
@ -407,8 +407,8 @@
{
"code": "CONTAINS_VIOLATED",
"values": {
"limit": "2",
"count": "3"
"count": "3",
"limit": "2"
},
"details": {
"path": "",
@ -538,8 +538,8 @@
{
"code": "CONTAINS_VIOLATED",
"values": {
"limit": "3",
"count": "2"
"count": "2",
"limit": "3"
},
"details": {
"path": "",
@ -549,8 +549,8 @@
{
"code": "CONTAINS_VIOLATED",
"values": {
"limit": "1",
"count": "2"
"count": "2",
"limit": "1"
},
"details": {
"path": "",

View File

@ -48,8 +48,8 @@
{
"code": "MIN_ITEMS_VIOLATED",
"values": {
"limit": "1",
"count": "0"
"count": "0",
"limit": "1"
},
"details": {
"path": "",

View File

@ -43,8 +43,8 @@
{
"code": "MIN_LENGTH_VIOLATED",
"values": {
"limit": "2",
"count": "1"
"count": "1",
"limit": "2"
},
"details": {
"path": "",

View File

@ -149,8 +149,8 @@
{
"code": "MINIMUM_VIOLATED",
"values": {
"limit": "-2",
"value": "-3"
"value": "-3",
"limit": "-2"
},
"details": {
"path": "",

View File

@ -100,8 +100,8 @@
{
"code": "MULTIPLE_OF_VIOLATED",
"values": {
"multiple_of": "1.5",
"value": "35"
"value": "35",
"multiple_of": "1.5"
},
"details": {
"path": "",

View File

@ -191,6 +191,9 @@
},
{
"name": "request",
"field_types": {
"inv": "jsonb"
},
"schemas": {
"request": {
"type": "object",

View File

@ -200,8 +200,8 @@
{
"code": "MINIMUM_VIOLATED",
"values": {
"limit": "10",
"value": "5"
"value": "5",
"limit": "10"
},
"details": {
"path": "entities/entity-beta/value",

View File

@ -591,8 +591,8 @@
{
"code": "NO_ONEOF_MATCH",
"values": {
"value": "alien",
"discriminator": "type"
"discriminator": "type",
"value": "alien"
},
"details": {
"path": "",
@ -931,8 +931,8 @@
{
"code": "NO_FAMILY_MATCH",
"values": {
"value": "unknown_panel",
"discriminator": "kind"
"discriminator": "kind",
"value": "unknown_panel"
},
"details": {
"path": "",

View File

@ -873,5 +873,77 @@
}
}
]
},
{
"description": "immutable property validation in request vs response context",
"database": {
"types": [
{
"name": "item",
"schemas": {
"save_item.request": {
"properties": {
"id": {
"type": "string"
},
"created_at": {
"type": "string",
"immutable": true
}
}
},
"get_item.response": {
"properties": {
"id": {
"type": "string"
},
"created_at": {
"type": "string",
"immutable": true
}
}
}
}
}
]
},
"tests": [
{
"description": "immutable property in request context causes IMMUTABLE_PROPERTY_VIOLATION",
"data": {
"id": "123",
"created_at": "2026-07-21T00:00:00Z"
},
"schema_id": "save_item.request",
"action": "validate",
"expect": {
"success": false,
"errors": [
{
"code": "IMMUTABLE_PROPERTY_VIOLATION",
"values": {
"property_name": "created_at"
},
"details": {
"path": "created_at",
"schema": "save_item.request"
}
}
]
}
},
{
"description": "immutable property in response context is allowed",
"data": {
"id": "123",
"created_at": "2026-07-21T00:00:00Z"
},
"schema_id": "get_item.response",
"action": "validate",
"expect": {
"success": true
}
}
]
}
]

View File

@ -43,8 +43,8 @@
{
"code": "MAX_LENGTH_VIOLATED",
"values": {
"limit": "3",
"count": "6"
"count": "6",
"limit": "3"
},
"details": {
"path": "",

View File

@ -1,6 +1,6 @@
[
{
"description": "Queryer Execution",
"description": "Queryer Suite 1",
"database": {
"puncs": [
{
@ -258,9 +258,7 @@
"name": "text",
"created_at": "timestamptz"
},
"lookup_fields": [
"id"
],
"lookup_fields": [],
"null_fields": [],
"default_fields": [
"id",
@ -806,9 +804,7 @@
"archived"
]
},
"lookup_fields": [
"id"
],
"lookup_fields": [],
"historical": true,
"relationship": false,
"field_types": {
@ -1106,9 +1102,7 @@
"archived"
]
},
"lookup_fields": [
"id"
],
"lookup_fields": [],
"historical": true,
"relationship": false,
"field_types": {
@ -2439,5 +2433,276 @@
}
}
]
},
{
"description": "Queryer Suite 2",
"database": {
"puncs": [
{
"name": "get_pet",
"schemas": {
"get_pet.response": {
"type": "full.pet"
}
}
}
],
"enums": [],
"relations": [
{
"type": "relation",
"id": "00000000-0000-0000-0000-000000000001",
"constraint": "fk_attachment_attachable_entity",
"source_type": "attachment",
"source_columns": [
"attachable_id",
"attachable_type"
],
"destination_type": "entity",
"destination_columns": [
"id",
"type"
],
"prefix": "attachable"
}
],
"types": [
{
"name": "entity",
"hierarchy": [
"entity"
],
"grouped_fields": {
"entity": [
"id",
"type",
"archived"
]
},
"field_types": {
"id": "uuid",
"archived": "boolean",
"type": "text"
},
"schemas": {
"entity": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string"
},
"archived": {
"type": "boolean"
}
}
}
},
"fields": [
"id",
"type",
"archived"
],
"variations": [
"entity",
"pet",
"attachment"
]
},
{
"name": "pet",
"hierarchy": [
"entity",
"pet"
],
"grouped_fields": {
"entity": [
"id",
"type",
"archived"
],
"pet": [
"name"
]
},
"field_types": {
"name": "text"
},
"schemas": {
"pet": {
"type": "entity",
"properties": {
"name": {
"type": "string"
}
}
},
"full.pet": {
"type": "pet",
"properties": {
"cover_attachment": {
"type": "attachment",
"properties": {
"kind": {
"const": "cover"
}
}
},
"cover_attachments": {
"type": "array",
"items": {
"type": "attachment",
"properties": {
"kind": {
"const": "cover"
}
}
}
},
"media_attachments": {
"type": "array",
"items": {
"type": "attachment",
"properties": {
"kind": {
"enum": [
"cover",
"thumbnail"
]
}
}
}
}
}
}
},
"fields": [
"id",
"type",
"archived",
"name"
],
"variations": [
"pet"
]
},
{
"name": "attachment",
"hierarchy": [
"entity",
"attachment"
],
"grouped_fields": {
"entity": [
"id",
"type",
"archived"
],
"attachment": [
"attachable_id",
"name",
"kind"
]
},
"field_types": {
"attachable_id": "uuid",
"name": "text",
"kind": "text"
},
"schemas": {
"attachment": {
"type": "entity",
"properties": {
"attachable_id": {
"type": "string"
},
"name": {
"type": "string"
},
"kind": {
"type": "string"
}
}
}
},
"fields": [
"id",
"type",
"archived",
"attachable_id",
"name",
"kind"
],
"variations": [
"attachment"
]
}
]
},
"tests": [
{
"description": "Select query on pet with static properties constraints on attachments",
"action": "query",
"schema_id": "get_pet.response",
"expect": {
"success": true,
"sql": [
[
"((SELECT jsonb_strip_nulls((",
" SELECT jsonb_build_object(",
" 'name', pet_2.name,",
" 'cover_attachment', (",
" SELECT jsonb_build_object(",
" 'attachable_id', attachment_4.attachable_id,",
" 'name', attachment_4.name,",
" 'kind', attachment_4.kind",
" )",
" FROM agreego.entity entity_3",
" JOIN agreego.attachment attachment_4 ON attachment_4.id = entity_3.id",
" WHERE",
" NOT entity_3.archived",
" AND attachment_4.kind = 'cover'",
" AND attachment_4.attachable_id = entity_1.id",
" LIMIT 1",
" ),",
" 'cover_attachments', (",
" SELECT COALESCE(jsonb_agg(jsonb_build_object(",
" 'attachable_id', attachment_6.attachable_id,",
" 'name', attachment_6.name,",
" 'kind', attachment_6.kind",
" )), '[]'::jsonb)",
" FROM agreego.entity entity_5",
" JOIN agreego.attachment attachment_6 ON attachment_6.id = entity_5.id",
" WHERE",
" NOT entity_5.archived",
" AND attachment_6.kind = 'cover'",
" AND attachment_6.attachable_id = entity_1.id",
" ),",
" 'media_attachments', (",
" SELECT COALESCE(jsonb_agg(jsonb_build_object(",
" 'attachable_id', attachment_8.attachable_id,",
" 'name', attachment_8.name,",
" 'kind', attachment_8.kind",
" )), '[]'::jsonb)",
" FROM agreego.entity entity_7",
" JOIN agreego.attachment attachment_8 ON attachment_8.id = entity_7.id",
" WHERE",
" NOT entity_7.archived",
" AND attachment_8.kind IN ('cover', 'thumbnail')",
" AND attachment_8.attachable_id = entity_1.id",
" )",
" )",
" FROM agreego.entity entity_1",
" JOIN agreego.pet pet_2 ON pet_2.id = entity_1.id",
" WHERE",
" NOT entity_1.archived",
"))))"
]
]
}
}
]
}
]

1
flow
View File

@ -36,7 +36,6 @@ pgrx-down() {
info "Taking pgrx down..."
}
build() {
local version
version=$(get-version) || return $?

2
flows

Submodule flows updated: 0d9bd8644e...89748a246e

View File

@ -2,7 +2,7 @@ use crate::database::schema::Schema;
#[allow(unused_imports)]
use crate::drop::{Error, ErrorDetails};
#[allow(unused_imports)]
use std::collections::HashMap;
use indexmap::IndexMap;
use std::sync::Arc;
impl Schema {
@ -19,7 +19,7 @@ impl Schema {
if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_' && c != '.' && c != '$' {
errors.push(Error {
code: "INVALID_IDENTIFIER".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("character".to_string(), c.to_string()),
("field_name".to_string(), field_name.to_string()),
("identifier".to_string(), id.to_string()),

View File

@ -165,20 +165,8 @@ impl Schema {
} else if db.enums.contains_key(custom) {
Some(vec![format!("{}.condition", custom)])
} else {
// A named type gets a reference to its dynamically built .filter — either a
// Relational cross-boundary (table-backed) or a named value type, whose filter
// is likewise synthesized (see Database::compile_filters). A named type with no
// compilable structure gets no filter — omit it rather than dangle a reference.
let base = custom.split('.').next_back().unwrap_or(custom);
let has_value_filter = db
.schemas
.get(custom)
.map_or(false, |s| Database::is_value_filter_candidate(custom, s));
if db.types.contains_key(base) || has_value_filter {
Some(vec![format!("{}.filter", custom)])
} else {
None
}
// Assume anything else is a Relational cross-boundary that already has its own .filter dynamically built
Some(vec![format!("{}.filter", custom)])
}
}
}

View File

@ -7,7 +7,6 @@ pub mod polymorphism;
use crate::database::schema::Schema;
use crate::drop::{Error, ErrorDetails};
use indexmap::IndexMap;
use std::collections::HashMap;
impl Schema {
pub fn compile(
@ -64,7 +63,7 @@ impl Schema {
if custom_type_count > 1 {
errors.push(Error {
code: "MULTIPLE_INHERITANCE_PROHIBITED".to_string(),
values: Some(HashMap::from([("types".to_string(), types.join(", "))])),
values: Some(IndexMap::from([("types".to_string(), types.join(", "))])),
details: ErrorDetails {
path: Some(path.clone()),
schema: Some(root_id.to_string()),

View File

@ -1,6 +1,6 @@
use crate::drop::{Error, ErrorDetails};
use indexmap::IndexSet;
use std::collections::HashMap;
use indexmap::IndexMap;
use crate::database::schema::Schema;
impl Schema {
@ -144,7 +144,7 @@ impl Schema {
if options.contains_key(&val) {
errors.push(Error {
code: "POLYMORPHIC_COLLISION".to_string(),
values: Some(HashMap::from([("value".to_string(), val.to_string())])),
values: Some(IndexMap::from([("value".to_string(), val.to_string())])),
details: ErrorDetails {
path: Some(path.to_string()),
schema: Some(root_id.to_string()),

View File

@ -1,6 +1,7 @@
use crate::drop::{Error, ErrorDetails};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use indexmap::IndexMap;
pub fn compose(val: &mut Value, errors: &mut Vec<Error>) {
let mut traits = HashMap::new();
@ -114,7 +115,7 @@ fn resolve_in_place(
if visited.contains(inc_name) {
errors.push(Error {
code: "CIRCULAR_INCLUDE_DETECTED".to_string(),
values: Some(HashMap::from([(
values: Some(IndexMap::from([(
"include".to_string(),
inc_name.to_string(),
)])),
@ -218,7 +219,7 @@ fn resolve_in_place(
} else {
errors.push(Error {
code: "TRAIT_NOT_FOUND".to_string(),
values: Some(HashMap::from([(
values: Some(IndexMap::from([(
"include".to_string(),
inc_name.to_string(),
)])),

View File

@ -21,16 +21,22 @@ use executors::pgrx::SpiExecutor;
#[cfg(test)]
use executors::mock::MockExecutor;
use indexmap::IndexMap;
use punc::Punc;
use relation::Relation;
use schema::Schema;
use serde_json::Value;
use indexmap::IndexMap;
use std::sync::Arc;
use r#type::Type;
use std::collections::HashMap;
use crate::drop::{Drop, Error, ErrorDetails};
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct IndexInfo {
pub table: String,
pub columns: Vec<String>,
}
#[derive(serde::Serialize)]
pub struct Database {
pub enums: IndexMap<String, Enum>,
@ -38,6 +44,8 @@ pub struct Database {
pub puncs: IndexMap<String, Punc>,
pub relations: IndexMap<String, Relation>,
#[serde(skip)]
pub indexes: Vec<IndexInfo>,
#[serde(skip)]
pub schemas: IndexMap<String, Arc<Schema>>,
#[serde(skip)]
pub executor: Box<dyn DatabaseExecutor + Send + Sync>,
@ -50,6 +58,7 @@ impl Database {
types: IndexMap::new(),
relations: IndexMap::new(),
puncs: IndexMap::new(),
indexes: Vec::new(),
schemas: IndexMap::new(),
#[cfg(not(test))]
executor: Box::new(SpiExecutor::new()),
@ -76,7 +85,7 @@ impl Database {
Err(e) => {
errors.push(Error {
code: "DATABASE_ENUM_PARSE_FAILED".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("enum".to_string(), name.clone()),
("reason".to_string(), e.to_string()),
])),
@ -104,7 +113,7 @@ impl Database {
Err(e) => {
errors.push(Error {
code: "DATABASE_TYPE_PARSE_FAILED".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("type".to_string(), name.clone()),
("reason".to_string(), e.to_string()),
])),
@ -136,7 +145,7 @@ impl Database {
Err(e) => {
errors.push(Error {
code: "DATABASE_RELATION_PARSE_FAILED".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("relation".to_string(), constraint.clone()),
("reason".to_string(), e.to_string()),
])),
@ -164,7 +173,7 @@ impl Database {
Err(e) => {
errors.push(Error {
code: "DATABASE_PUNC_PARSE_FAILED".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("punc".to_string(), name.clone()),
("reason".to_string(), e.to_string()),
])),
@ -177,6 +186,25 @@ impl Database {
}
}
}
if let Some(serde_json::Value::Array(arr)) = map.remove("indexes") {
for item in arr {
match serde_json::from_value::<IndexInfo>(item) {
Ok(idx) => {
db.indexes.push(idx);
}
Err(e) => {
errors.push(Error {
code: "DATABASE_INDEX_PARSE_FAILED".to_string(),
values: Some(IndexMap::from([("reason".to_string(), e.to_string())])),
details: ErrorDetails {
..Default::default()
},
});
}
}
}
}
}
db.compile(&mut errors);
@ -214,7 +242,6 @@ impl Database {
self.executor.timestamp()
}
pub fn compile(&mut self, errors: &mut Vec<crate::drop::Error>) {
// Phase 1: Registration
self.collect_schemas(errors);
@ -260,25 +287,54 @@ impl Database {
.compile(self, root_id, id.clone(), errors);
}
}
// Phase 5: Verify unique indexes for defined lookup fields
for (_, type_def) in &self.types {
if !type_def.lookup_fields.is_empty() {
let mut index_found = false;
for index in &self.indexes {
if index.table == type_def.name {
if index.columns.len() == type_def.lookup_fields.len()
&& index
.columns
.iter()
.all(|c| type_def.lookup_fields.contains(c))
{
index_found = true;
break;
}
}
}
if !index_found {
errors.push(Error {
code: "MISSING_LOOKUP_INDEX".to_string(),
values: Some(IndexMap::from([
("type".to_string(), type_def.name.clone()),
(
"lookup_fields".to_string(),
format!("{:?}", type_def.lookup_fields),
),
])),
details: ErrorDetails {
path: Some(format!("/types/{}", type_def.name)),
schema: Some(type_def.name.clone()),
..Default::default()
},
});
}
}
}
}
/// Synthesizes Composed Filter References for all table-backed boundaries — and for
/// named non-table value types (schema-only objects, e.g. an operating-hours config),
/// so a property reference resolves to ONE named filter instead of inlining anonymous
/// per-path copies (which duplicate identical leaf type names for downstream codegen).
/// Synthesizes Composed Filter References for all table-backed boundaries.
fn compile_filters(&mut self, errors: &mut Vec<crate::drop::Error>) -> Vec<(String, String)> {
let mut filter_schemas = Vec::new();
let mut seen_value_ids = std::collections::HashSet::new();
for (type_name, type_def) in &self.types {
for (id, schema_arc) in &type_def.schemas {
// Run synthesis on structured table-backed boundaries and named value types.
// Exclude subschemas!
// Only run synthesis on actual structured, table-backed boundaries. Exclude subschemas!
let base_name = id.split('.').last().unwrap_or(id);
let is_table_backed = base_name == type_def.name;
let is_value_type = !is_table_backed
&& Self::is_value_filter_candidate(id, schema_arc)
&& seen_value_ids.insert(id.clone());
if (is_table_backed || is_value_type) && !id.contains('/') {
if is_table_backed && !id.contains('/') {
if let Some(filter_schema) = schema_arc.compile_filter(self, id, errors) {
filter_schemas.push((
type_name.clone(),
@ -301,20 +357,6 @@ impl Database {
filter_ids
}
/// A named non-table value type that earns its own synthesized filter: a bare-named
/// (dotless) schema-only object with compiled properties. The base `filter`/`condition`
/// schemas are infrastructure, not value types.
pub fn is_value_filter_candidate(id: &str, schema: &Arc<Schema>) -> bool {
!id.contains('.')
&& id != "filter"
&& id != "condition"
&& schema
.obj
.compiled_properties
.get()
.map_or(false, |props| !props.is_empty())
}
/// Synthesizes strong Enum Conditions mirroring the string.condition capabilities.
fn compile_conditions(&mut self) -> Vec<(String, String)> {
let mut enum_conditions = Vec::new();
@ -417,7 +459,6 @@ impl Database {
}
}
/// Inspects the Postgres pg_constraint relations catalog to securely identify
/// the precise Foreign Key connecting a parent and child hierarchy path.
pub fn resolve_relation<'a>(
@ -483,7 +524,7 @@ impl Database {
errors.push(Error {
code: "EDGE_MISSING".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("parent_type".to_string(), parent_type.to_string()),
("child_type".to_string(), child_type.to_string()),
("property_name".to_string(), prop_name.to_string()),
@ -585,7 +626,7 @@ impl Database {
errors.push(Error {
code: "AMBIGUOUS_TYPE_RELATIONS".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("parent_type".to_string(), parent_type.to_string()),
("child_type".to_string(), child_type.to_string()),
("property_name".to_string(), prop_name.to_string()),

View File

@ -149,6 +149,9 @@ pub struct SchemaObject {
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub extensible: Option<bool>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub immutable: Option<bool>,
// Contains ALL structural fields perfectly flattened from the ENTIRE Database inheritance tree (e.g. `entity` fields like `id`) as well as local fields hidden inside conditional `cases` blocks.
// This JSON exported array gives clients absolute deterministic visibility to O(1) validation and masking bounds without duplicating structural memory.
@ -306,4 +309,8 @@ impl SchemaObject {
false
}
pub fn is_immutable(&self) -> bool {
self.immutable == Some(true)
}
}

View File

@ -1,6 +1,7 @@
use crate::database::action::Action;
use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
@ -13,10 +14,11 @@ pub struct Page {
pub sidebar: Option<Sidebar>,
#[serde(skip_serializing_if = "Option::is_none")]
pub actions: Option<IndexMap<String, Action>>,
/// The personal-scope sibling punc for this page ("one surface, two scopes"):
/// the caller-scoped read the page issues when the app is in personal scope.
/// The personal-scope filter template ("one surface, two scopes"): the
/// "mine" predicate the page merges into its filter when the app is in
/// personal scope, e.g. {"reserved_by": "$me"}. Opaque to the engine.
#[serde(skip_serializing_if = "Option::is_none")]
pub personal: Option<String>,
pub personal: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
@ -26,6 +28,4 @@ pub struct Sidebar {
pub category: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub priority: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
}

View File

@ -57,13 +57,13 @@ impl Drop {
}
}
use std::collections::HashMap;
use indexmap::IndexMap;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Error {
pub code: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub values: Option<HashMap<String, String>>,
pub values: Option<IndexMap<String, String>>,
pub details: ErrorDetails,
}

View File

@ -7,7 +7,7 @@ use crate::database::Database;
use crate::database::r#type::Type;
use crate::drop::{Drop, Error, ErrorDetails};
use serde_json::Value;
use std::collections::HashMap;
use indexmap::IndexMap;
use std::sync::Arc;
pub struct Merger {
@ -31,7 +31,7 @@ impl Merger {
None => {
return Drop::with_errors(vec![Error {
code: "SCHEMA_NOT_FOUND".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("schema".to_string(), schema_id.to_string()),
])),
details: ErrorDetails {
@ -48,47 +48,7 @@ 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 Drop::with_errors(vec![Error {
code: final_code,
values: Some(HashMap::from([
("error".to_string(), final_message),
])),
details: ErrorDetails {
path: None,
cause: final_cause,
context: None,
schema: None,
},
}]);
}
Err(err) => return Drop::with_errors(vec![err]),
};
// Execute the globally collected, pre-ordered notifications last!
@ -96,12 +56,12 @@ impl Merger {
if let Err(e) = self.db.execute(&notify_sql, None) {
return Drop::with_errors(vec![Error {
code: "MERGE_FAILED".to_string(),
values: Some(HashMap::from([
("error".to_string(), e.to_string()),
values: Some(IndexMap::from([
("error".to_string(), e.clone()),
])),
details: ErrorDetails {
path: None,
cause: None,
cause: Some(e),
context: None,
schema: None,
},
@ -144,7 +104,7 @@ impl Merger {
notifications: &mut Vec<String>,
parent_org_id: Option<String>,
is_child: bool,
) -> Result<Value, String> {
) -> Result<Value, Error> {
match data {
Value::Array(items) => {
self.merge_array(schema, items, notifications, parent_org_id, is_child)
@ -159,10 +119,14 @@ impl Merger {
if let Some(target_schema) = self.db.schemas.get(target_id) {
schema = target_schema.clone();
} else {
return Err(format!(
"Polymorphic mapped target '{}' not found in database registry",
target_id
));
return Err(Error {
code: "TARGET_SCHEMA_NOT_FOUND".to_string(),
values: Some(IndexMap::from([("target_id".to_string(), target_id.clone())])),
details: ErrorDetails {
cause: Some(format!("Polymorphic mapped target '{}' not found in database registry", target_id)),
..Default::default()
},
});
}
} else if let Some(idx) = idx_opt {
if let Some(target_schema) = schema
@ -173,31 +137,60 @@ impl Merger {
{
schema = Arc::clone(target_schema);
} else {
return Err(format!(
"Polymorphic index target '{}' not found in local oneOf array",
idx
));
return Err(Error {
code: "ONE_OF_INDEX_NOT_FOUND".to_string(),
values: Some(IndexMap::from([("index".to_string(), idx.to_string())])),
details: ErrorDetails {
cause: Some(format!("Polymorphic index target '{}' not found in local oneOf array", idx)),
..Default::default()
},
});
}
} else {
return Err(format!("Polymorphic mapped target has no path"));
return Err(Error {
code: "INVALID_POLYMORPHIC_TARGET".to_string(),
values: None,
details: ErrorDetails {
cause: Some("Polymorphic mapped target has no path".to_string()),
..Default::default()
},
});
}
} else {
return Err(format!(
"Polymorphic discriminator {}='{}' matched no compiled options",
disc, v
));
return Err(Error {
code: "DISCRIMINATOR_MISMATCH".to_string(),
values: Some(IndexMap::from([
("discriminator".to_string(), disc.to_string()),
("value".to_string(), v.to_string()),
])),
details: ErrorDetails {
cause: Some(format!("Polymorphic discriminator {}='{}' matched no compiled options", disc, v)),
..Default::default()
},
});
}
} else {
return Err(format!(
"Polymorphic merging failed: missing required discriminator '{}'",
disc
));
return Err(Error {
code: "MISSING_DISCRIMINATOR".to_string(),
values: Some(IndexMap::from([("discriminator".to_string(), disc.to_string())])),
details: ErrorDetails {
cause: Some(format!("Polymorphic merging failed: missing required discriminator '{}'", disc)),
..Default::default()
},
});
}
}
}
self.merge_object(schema, map, notifications, parent_org_id, is_child)
}
_ => Err("Invalid merge payload: root must be an Object or Array".to_string()),
_ => Err(Error {
code: "INVALID_MERGE_PAYLOAD".to_string(),
values: None,
details: ErrorDetails {
cause: Some("Invalid merge payload: root must be an Object or Array".to_string()),
..Default::default()
},
}),
}
}
@ -208,7 +201,7 @@ impl Merger {
notifications: &mut Vec<String>,
parent_org_id: Option<String>,
is_child: bool,
) -> Result<Value, String> {
) -> Result<Value, Error> {
let mut item_schema = schema.clone();
if let Some(crate::database::object::SchemaTypeOrArray::Single(t)) = &schema.obj.type_ {
if t == "array" {
@ -239,22 +232,49 @@ impl Merger {
notifications: &mut Vec<String>,
parent_org_id: Option<String>,
is_child: bool,
) -> Result<Value, String> {
) -> Result<Value, Error> {
let queue_start = notifications.len();
let type_name = match obj.get("type").and_then(|v| v.as_str()) {
Some(t) => t.to_string(),
None => return Err("Missing required 'type' field on object".to_string()),
None => {
return Err(Error {
code: "MISSING_TYPE".to_string(),
values: None,
details: ErrorDetails {
cause: Some("Missing required 'type' field on object".to_string()),
..Default::default()
},
});
}
};
let type_def = match self.db.types.get(&type_name) {
Some(t) => t,
None => return Err(format!("Unknown entity type: {}", type_name)),
None => {
return Err(Error {
code: "UNKNOWN_ENTITY_TYPE".to_string(),
values: Some(IndexMap::from([("type".to_string(), type_name.clone())])),
details: ErrorDetails {
cause: Some(format!("Unknown entity type: {}", type_name)),
..Default::default()
},
});
}
};
let compiled_props = match schema.obj.compiled_properties.get() {
Some(props) => props,
None => return Err("Schema has no compiled properties for merging".to_string()),
None => {
return Err(Error {
code: "UNCOMPILED_SCHEMA".to_string(),
values: None,
details: ErrorDetails {
cause: Some("Schema has no compiled properties for merging".to_string()),
..Default::default()
},
});
}
};
let mut entity_fields = serde_json::Map::new();
@ -269,6 +289,10 @@ impl Merger {
}
if let Some(prop_schema) = compiled_props.get(&k) {
if prop_schema.is_immutable() {
continue;
}
let mut is_edge = false;
if let Some(edges) = schema.obj.compiled_edges.get() {
if edges.contains_key(&k) {
@ -312,8 +336,22 @@ impl Merger {
current_org_id = parent_org_id.clone();
}
let user_id = self.db.auth_user_id()?;
let timestamp = self.db.timestamp()?;
let user_id = self.db.auth_user_id().map_err(|e| Error {
code: "AUTH_USER_FAILED".to_string(),
values: Some(IndexMap::from([("error".to_string(), e.clone())])),
details: ErrorDetails {
cause: Some(e),
..Default::default()
},
})?;
let timestamp = self.db.timestamp().map_err(|e| Error {
code: "TIMESTAMP_FAILED".to_string(),
values: Some(IndexMap::from([("error".to_string(), e.clone())])),
details: ErrorDetails {
cause: Some(e),
..Default::default()
},
})?;
let mut entity_change_kind = None;
let mut entity_fetched = None;
@ -328,6 +366,27 @@ impl Merger {
entity_replaces = replaces;
if entity_change_kind.as_deref() == Some("create") {
if let Some(deps) = &schema.obj.dependencies {
if let Some(crate::database::object::Dependency::Props(req_props)) = deps.get("created") {
for req in req_props {
if !entity_fields.contains_key(req) && !entity_objects.contains_key(req) && !entity_arrays.contains_key(req) {
return Err(Error {
code: "REQUIRED_FIELD_MISSING".to_string(),
values: Some(IndexMap::from([
("property_name".to_string(), req.to_string()),
("entity_type".to_string(), type_name.clone()),
])),
details: ErrorDetails {
path: Some(req.to_string()),
cause: Some(format!("Missing required creation field '{}' for entity {}", req, type_name)),
..Default::default()
},
});
}
}
}
}
if is_child {
if !entity_fields.contains_key("organization_id") {
if let Some(ref org_id) = current_org_id {
@ -538,7 +597,7 @@ impl Merger {
Option<serde_json::Map<String, Value>>,
Option<String>,
),
String,
Error,
> {
let type_name = type_def.name.as_str();
@ -673,29 +732,36 @@ impl Merger {
&self,
entity_fields: &serde_json::Map<String, Value>,
entity_type: &crate::database::r#type::Type,
) -> Result<Option<serde_json::Map<String, Value>>, String> {
) -> Result<Option<serde_json::Map<String, Value>>, Error> {
let id_val = entity_fields.get("id");
let entity_type_name = entity_type.name.as_str();
let mut lookup_complete = false;
if !entity_type.lookup_fields.is_empty() {
lookup_complete = true;
for column in &entity_type.lookup_fields {
match entity_fields.get(column) {
Some(Value::Null) | None => {
lookup_complete = false;
break;
let mut lookup_satisfied_keys = Vec::new();
for parent_type_name in entity_type.hierarchy.iter().rev() {
if let Some(parent_type) = self.db.types.get(parent_type_name) {
if !parent_type.lookup_fields.is_empty() {
let mut lookup_complete = true;
for column in &parent_type.lookup_fields {
match entity_fields.get(column) {
Some(Value::Null) | None => {
lookup_complete = false;
break;
}
Some(Value::String(s)) if s.is_empty() => {
lookup_complete = false;
break;
}
_ => {}
}
}
Some(Value::String(s)) if s.is_empty() => {
lookup_complete = false;
break;
if lookup_complete {
lookup_satisfied_keys.push(&parent_type.lookup_fields);
}
_ => {}
}
}
}
if id_val.is_none() && !lookup_complete {
if id_val.is_none() && lookup_satisfied_keys.is_empty() {
return Ok(None);
}
@ -727,9 +793,9 @@ impl Merger {
where_parts.push(format!("t1.id = {}", Self::quote_literal(id)));
}
if lookup_complete {
for lookup_fields in lookup_satisfied_keys {
let mut lookup_predicates = Vec::new();
for column in &entity_type.lookup_fields {
for column in lookup_fields {
let val = entity_fields.get(column).unwrap_or(&Value::Null);
if column == "type" {
lookup_predicates.push(format!("t1.\"{}\" = {}", column, Self::quote_literal(val)));
@ -744,29 +810,60 @@ impl Merger {
return Ok(None);
}
let where_clause = format!("WHERE {}", where_parts.join(" OR "));
let final_sql = format!("{} {}", fetch_sql_template, where_clause);
let final_sql = if where_parts.len() == 1 {
format!("{} WHERE {}", fetch_sql_template, where_parts[0])
} else {
where_parts
.iter()
.map(|p| format!("{} WHERE {}", fetch_sql_template, p))
.collect::<Vec<_>>()
.join(" UNION ")
};
let fetched = match self.db.query(&final_sql, None) {
Ok(Value::Array(table)) => {
if table.len() > 1 {
Err(format!(
"TOO_MANY_LOOKUP_ROWS: Lookup for {} found too many existing rows",
entity_type_name
))
Err(Error {
code: "TOO_MANY_LOOKUP_ROWS".to_string(),
values: Some(IndexMap::from([("entity_type".to_string(), entity_type_name.to_string())])),
details: ErrorDetails {
cause: Some(format!("Lookup for {} found too many existing rows", entity_type_name)),
..Default::default()
},
})
} else if table.is_empty() {
Ok(None)
} else {
let row = table.first().unwrap();
match row {
Value::Object(map) => Ok(Some(map.clone())),
other => Err(format!("Expected JSON object, got: {:?}", other)),
other => Err(Error {
code: "UNEXPECTED_QUERY_RESULT".to_string(),
values: None,
details: ErrorDetails {
cause: Some(format!("Expected JSON object, got: {:?}", other)),
..Default::default()
},
}),
}
}
}
Ok(_) => Err("Expected array from query in fetch_entity".to_string()),
Err(e) => Err(format!("SPI error in fetch_entity: {:?}", e)),
Ok(_) => Err(Error {
code: "UNEXPECTED_QUERY_RESULT".to_string(),
values: None,
details: ErrorDetails {
cause: Some("Expected array from query in fetch_entity".to_string()),
..Default::default()
},
}),
Err(e) => Err(Error {
code: "DATABASE_SPI_ERROR".to_string(),
values: Some(IndexMap::from([("error".to_string(), e.clone())])),
details: ErrorDetails {
cause: Some(format!("SPI error in fetch_entity: {:?}", e)),
..Default::default()
},
}),
}?;
Ok(fetched)
@ -779,23 +876,36 @@ impl Merger {
entity_type: &crate::database::r#type::Type,
entity_fields: &serde_json::Map<String, Value>,
_entity_fetched: Option<&serde_json::Map<String, Value>>,
) -> Result<(), String> {
) -> Result<(), Error> {
if change_kind.is_empty() {
return Ok(());
}
let id_str = match entity_fields.get("id").and_then(|v| v.as_str()) {
Some(id) => id,
None => return Err("Missing 'id' for merge execution".to_string()),
None => {
return Err(Error {
code: "MISSING_ENTITY_ID".to_string(),
values: None,
details: ErrorDetails {
cause: Some("Missing 'id' for merge execution".to_string()),
..Default::default()
},
});
}
};
let grouped_fields = match &entity_type.grouped_fields {
Some(Value::Object(map)) => map,
_ => {
return Err(format!(
"Grouped fields missing for type {}",
entity_type_name
));
return Err(Error {
code: "MISSING_GROUPED_FIELDS".to_string(),
values: Some(IndexMap::from([("type".to_string(), entity_type_name.to_string())])),
details: ErrorDetails {
cause: Some(format!("Grouped fields missing for type {}", entity_type_name)),
..Default::default()
},
});
}
};
@ -848,7 +958,16 @@ impl Merger {
columns.join(", "),
values.join(", ")
);
self.db.execute(&sql, None)?;
if let Err(e) = self.db.execute(&sql, None) {
return Err(Error {
code: "DATABASE_SPI_ERROR".to_string(),
values: Some(IndexMap::from([("error".to_string(), e.clone())])),
details: ErrorDetails {
cause: Some(e),
..Default::default()
},
});
}
} else if change_kind == "update" || change_kind == "delete" {
entity_pairs.remove("id");
entity_pairs.remove("type");
@ -880,7 +999,16 @@ impl Merger {
set_clauses.join(", "),
Self::quote_literal(&Value::String(id_str.to_string()))
);
self.db.execute(&sql, None)?;
if let Err(e) = self.db.execute(&sql, None) {
return Err(Error {
code: "DATABASE_SPI_ERROR".to_string(),
values: Some(IndexMap::from([("error".to_string(), e.clone())])),
details: ErrorDetails {
cause: Some(e),
..Default::default()
},
});
}
}
}
@ -896,7 +1024,7 @@ impl Merger {
user_id: &str,
timestamp: &str,
replaces_id: Option<&str>,
) -> Result<Option<String>, String> {
) -> Result<Option<String>, Error> {
let change_kind = match entity_change_kind {
Some(k) => k,
None => return Ok(None),
@ -989,7 +1117,16 @@ impl Merger {
Self::quote_literal(&Value::String(user_id.to_string()))
);
self.db.execute(&change_sql, None)?;
if let Err(e) = self.db.execute(&change_sql, None) {
return Err(Error {
code: "DATABASE_SPI_ERROR".to_string(),
values: Some(IndexMap::from([("error".to_string(), e.clone())])),
details: ErrorDetails {
cause: Some(e),
..Default::default()
},
});
}
}
if type_obj.notify {

View File

@ -1,5 +1,6 @@
use crate::database::Database;
use indexmap::IndexMap;
use serde_json::Value;
use std::sync::Arc;
pub struct Compiler<'a> {
@ -120,6 +121,21 @@ impl<'a> Compiler<'a> {
}
fn compile_reference(&mut self, node: Node<'a>) -> Result<(String, String), String> {
// Handle Direct Refs via type pointer first
if let Some(crate::database::object::SchemaTypeOrArray::Single(t)) = &node.schema.obj.type_ {
if !crate::database::object::is_primitive_type(t) {
if !self.db.types.contains_key(t) {
// If it's just an ad-hoc struct ref, we should resolve it
if let Some(target_schema) = self.db.schemas.get(t).cloned() {
let mut ref_node = node.clone();
ref_node.schema = target_schema.clone();
ref_node.schema_id = Some(t.clone());
return self.compile_node(ref_node);
}
}
}
}
// Determine if this schema represents a Database Entity
let mut resolved_type = None;
@ -149,16 +165,9 @@ impl<'a> Compiler<'a> {
return self.compile_entity(type_def, node.clone(), false);
}
// Handle Direct Refs via type pointer
// Fallback error if the schema pointer was unresolved
if let Some(crate::database::object::SchemaTypeOrArray::Single(t)) = &node.schema.obj.type_ {
if !crate::database::object::is_primitive_type(t) {
// If it's just an ad-hoc struct ref, we should resolve it
if let Some(target_schema) = self.db.schemas.get(t).cloned() {
let mut ref_node = node.clone();
ref_node.schema = target_schema.clone();
ref_node.schema_id = Some(t.clone());
return self.compile_node(ref_node);
}
return Err(format!("Unresolved schema type pointer: {}", t));
}
}
@ -231,7 +240,7 @@ impl<'a> Compiler<'a> {
};
// 3. Build WHERE clauses
let where_clauses = self.compile_where_clause(r#type, &table_aliases, node)?;
let where_clauses = self.compile_where_clause(r#type, &table_aliases, node.clone())?;
let selection = if is_array {
format!("COALESCE(jsonb_agg({}), '[]'::jsonb)", jsonb_obj_sql)
@ -239,11 +248,38 @@ impl<'a> Compiler<'a> {
jsonb_obj_sql
};
let mut limit_clause = "";
if !is_array {
let is_reverse = if let Some(prop_ref) = &node.property_name {
let prop = prop_ref.as_str();
if let Some(parent_schema) = &node.parent_schema {
if let Some(compiled_edges) = parent_schema.obj.compiled_edges.get() {
if let Some(edge) = compiled_edges.get(prop) {
!edge.forward
} else {
false
}
} else {
false
}
} else {
false
}
} else {
false
};
if is_reverse {
limit_clause = " LIMIT 1";
}
}
let full_sql = format!(
"(SELECT {} FROM {} WHERE {})",
"(SELECT {} FROM {} WHERE {}{})",
selection,
from_clauses.join(" "),
where_clauses.join(" AND ")
where_clauses.join(" AND "),
limit_clause
);
Ok((
@ -551,6 +587,7 @@ impl<'a> Compiler<'a> {
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_static_property_conditions(r#type, type_aliases, &node, &base_alias, &mut where_clauses);
let start_len = where_clauses.len();
self.compile_relation_conditions(
@ -611,9 +648,6 @@ impl<'a> Compiler<'a> {
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 {
@ -865,4 +899,69 @@ impl<'a> Compiler<'a> {
}
Ok(())
}
fn compile_static_property_conditions(
&self,
r#type: &crate::database::r#type::Type,
type_aliases: &std::collections::HashMap<String, String>,
node: &Node,
base_alias: &str,
where_clauses: &mut Vec<String>,
) {
if let Some(props) = node.schema.obj.properties.as_ref() {
for (prop_name, prop_schema) in props {
let filter_alias = Self::resolve_filter_alias(r#type, type_aliases, base_alias, prop_name);
if let Some(const_val) = prop_schema.obj.const_.as_ref() {
let sql_val = Self::quote_literal(const_val);
where_clauses.push(format!("{}.{} = {}", filter_alias, prop_name, sql_val));
}
if let Some(enum_vals) = prop_schema.obj.enum_.as_ref() {
if !enum_vals.is_empty() {
let sql_vals: Vec<String> = enum_vals
.iter()
.map(|v| Self::quote_literal(v))
.collect();
where_clauses.push(format!(
"{}.{} IN ({})",
filter_alias,
prop_name,
sql_vals.join(", ")
));
}
}
}
}
}
fn quote_literal(val: &Value) -> String {
match val {
Value::Null => "NULL".to_string(),
Value::Bool(b) => {
if *b {
"true".to_string()
} else {
"false".to_string()
}
}
Value::Number(n) => {
if let Some(f) = n.as_f64() {
if f.fract() == 0.0 {
return f.trunc().to_string();
}
}
n.to_string()
}
Value::String(s) => {
if s.is_empty() {
"NULL".to_string()
} else {
format!("'{}'", s.replace('\'', "''"))
}
}
_ => format!(
"'{}'",
serde_json::to_string(val).unwrap().replace('\'', "''")
),
}
}
}

View File

@ -1,6 +1,6 @@
use crate::database::Database;
use crate::drop::{Drop, Error, ErrorDetails};
use std::collections::HashMap;
use indexmap::IndexMap;
use std::sync::Arc;
pub mod compiler;
@ -33,7 +33,7 @@ impl Queryer {
Err(msg) => {
return Drop::with_errors(vec![Error {
code: "FILTER_PARSE_FAILED".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("error".to_string(), msg.clone()),
])),
details: ErrorDetails {
@ -140,7 +140,7 @@ impl Queryer {
}
Err(e) => Err(Drop::with_errors(vec![Error {
code: "QUERY_COMPILATION_FAILED".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("error".to_string(), e.clone()),
])),
details: ErrorDetails {
@ -169,7 +169,7 @@ impl Queryer {
}
Ok(other) => Drop::with_errors(vec![Error {
code: "QUERY_FAILED".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("error".to_string(), format!("Expected array from generic query, got: {:?}", other)),
])),
details: ErrorDetails {
@ -181,7 +181,7 @@ impl Queryer {
}]),
Err(e) => Drop::with_errors(vec![Error {
code: "QUERY_FAILED".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("error".to_string(), e.to_string()),
])),
details: ErrorDetails {

View File

@ -1301,6 +1301,12 @@ fn test_queryer_0_15() {
crate::tests::runner::run_test_case(&path, 0, 15).unwrap();
}
#[test]
fn test_queryer_1_0() {
let path = format!("{}/fixtures/queryer.json", env!("CARGO_MANIFEST_DIR"));
crate::tests::runner::run_test_case(&path, 1, 0).unwrap();
}
#[test]
fn test_polymorphism_0_0() {
let path = format!("{}/fixtures/polymorphism.json", env!("CARGO_MANIFEST_DIR"));
@ -2513,6 +2519,18 @@ fn test_properties_12_0() {
crate::tests::runner::run_test_case(&path, 12, 0).unwrap();
}
#[test]
fn test_properties_13_0() {
let path = format!("{}/fixtures/properties.json", env!("CARGO_MANIFEST_DIR"));
crate::tests::runner::run_test_case(&path, 13, 0).unwrap();
}
#[test]
fn test_properties_13_1() {
let path = format!("{}/fixtures/properties.json", env!("CARGO_MANIFEST_DIR"));
crate::tests::runner::run_test_case(&path, 13, 1).unwrap();
}
#[test]
fn test_max_contains_0_0() {
let path = format!("{}/fixtures/maxContains.json", env!("CARGO_MANIFEST_DIR"));
@ -3401,6 +3419,18 @@ fn test_database_6_0() {
crate::tests::runner::run_test_case(&path, 6, 0).unwrap();
}
#[test]
fn test_database_7_0() {
let path = format!("{}/fixtures/database.json", env!("CARGO_MANIFEST_DIR"));
crate::tests::runner::run_test_case(&path, 7, 0).unwrap();
}
#[test]
fn test_database_8_0() {
let path = format!("{}/fixtures/database.json", env!("CARGO_MANIFEST_DIR"));
crate::tests::runner::run_test_case(&path, 8, 0).unwrap();
}
#[test]
fn test_cases_0_0() {
let path = format!("{}/fixtures/cases.json", env!("CARGO_MANIFEST_DIR"));
@ -7660,3 +7690,9 @@ fn test_merger_0_15() {
let path = format!("{}/fixtures/merger.json", env!("CARGO_MANIFEST_DIR"));
crate::tests::runner::run_test_case(&path, 0, 15).unwrap();
}
#[test]
fn test_merger_0_16() {
let path = format!("{}/fixtures/merger.json", env!("CARGO_MANIFEST_DIR"));
crate::tests::runner::run_test_case(&path, 0, 16).unwrap();
}

View File

@ -179,14 +179,38 @@ impl SqlFormatter {
}
fn format_query(&mut self, query: &Query) {
match &*query.body {
self.format_set_expr(&query.body);
if let Some(limit_clause) = &query.limit_clause {
self.push_line(&format!("{}", limit_clause.to_string().trim()));
}
}
fn format_set_expr(&mut self, set_expr: &SetExpr) {
match set_expr {
SetExpr::Select(select) => self.format_select(select),
SetExpr::Query(inner_query) => {
self.push_str("(");
self.format_query(inner_query);
self.push_str(")");
}
_ => self.push_str(&query.to_string()),
SetExpr::SetOperation { op, left, right, set_quantifier } => {
self.format_set_expr(left);
let op_str = match op {
sqlparser::ast::SetOperator::Union => "UNION",
sqlparser::ast::SetOperator::Intersect => "INTERSECT",
sqlparser::ast::SetOperator::Except => "EXCEPT",
_ => "UNION",
};
let quant_str = match set_quantifier {
sqlparser::ast::SetQuantifier::All => " ALL",
sqlparser::ast::SetQuantifier::Distinct => " DISTINCT",
sqlparser::ast::SetQuantifier::None => "",
_ => "",
};
self.push_line(&format!("{}{} ", op_str, quant_str));
self.format_set_expr(right);
}
_ => self.push_str(&set_expr.to_string()),
}
}
@ -236,20 +260,23 @@ impl SqlFormatter {
}
fn format_join(&mut self, join: &Join) {
let op = match &join.join_operator {
let op_str = match &join.join_operator {
JoinOperator::Inner(_) => "JOIN",
JoinOperator::LeftOuter(_) => "LEFT JOIN",
JoinOperator::Left(_) => "LEFT JOIN",
JoinOperator::Right(_) => "RIGHT JOIN",
_ => "JOIN",
};
self.push_str(&format!("{} {} ON ", op, join.relation));
self.push_str(&format!("{} {}", op_str, join.relation));
match &join.join_operator {
JoinOperator::Inner(JoinConstraint::On(expr)) => self.format_expr(expr),
JoinOperator::LeftOuter(JoinConstraint::On(expr)) => self.format_expr(expr),
JoinOperator::Join(JoinConstraint::On(expr)) => self.format_expr(expr),
_ => {
println!("FALLBACK JOIN OP: {:?}", join.join_operator);
JoinOperator::Inner(JoinConstraint::On(expr))
| JoinOperator::Left(JoinConstraint::On(expr))
| JoinOperator::Right(JoinConstraint::On(expr))
| JoinOperator::Join(JoinConstraint::On(expr)) => {
self.push_str(" ON ");
self.format_expr(expr);
}
_ => {}
}
}

View File

@ -259,3 +259,25 @@ pub fn update_sql_fixture(path: &str, suite_idx: usize, case_idx: usize, queries
let formatted_json = serde_json::to_string_pretty(&file_data).unwrap();
fs::write(path, formatted_json).unwrap();
}
pub fn update_schemas_fixture(path: &str, suite_idx: usize, case_idx: usize, db: &crate::database::Database) {
let content = fs::read_to_string(path).unwrap();
let mut file_data: Value = serde_json::from_str(&content).unwrap();
if let Some(expect) = file_data[suite_idx]["tests"][case_idx].get_mut("expect") {
if let Some(schemas_map) = expect.get_mut("schemas").and_then(|v| v.as_object_mut()) {
for (key, expected_val) in schemas_map {
if expected_val.is_object() && expected_val.as_object().unwrap().is_empty() {
continue;
}
if let Some(actual_ast) = db.schemas.get(key) {
let actual_val = serde_json::to_value(actual_ast).unwrap();
*expected_val = actual_val;
}
}
}
}
let formatted_json = serde_json::to_string_pretty(&file_data).unwrap();
fs::write(path, formatted_json).unwrap();
}

View File

@ -57,6 +57,9 @@ impl Case {
if env::var("UPDATE_EXPECT").is_ok() {
update_validation_fixture(path, suite_idx, case_idx, &result.errors);
if let Ok(db) = db_res {
crate::tests::runner::update_schemas_fixture(path, suite_idx, case_idx, db);
}
}
expect.assert_drop(&result)?;

View File

@ -16,6 +16,7 @@ pub struct ValidationContext<'a> {
pub reporter: bool,
pub overrides: HashSet<String>,
pub parents: Vec<&'a serde_json::Value>,
pub response: bool,
}
impl<'a> ValidationContext<'a> {
@ -27,6 +28,7 @@ impl<'a> ValidationContext<'a> {
overrides: HashSet<String>,
extensible: bool,
reporter: bool,
response: bool,
) -> Self {
let effective_extensible = schema.extensible.unwrap_or(extensible);
Self {
@ -40,6 +42,7 @@ impl<'a> ValidationContext<'a> {
reporter,
overrides,
parents: Vec::new(),
response,
}
}
@ -79,6 +82,7 @@ impl<'a> ValidationContext<'a> {
reporter,
overrides,
parents,
response: self.response,
}
}

View File

@ -1,9 +1,9 @@
use std::collections::HashMap;
use indexmap::IndexMap;
#[derive(Debug, Clone, serde::Serialize)]
pub struct ValidationError {
pub code: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub values: Option<HashMap<String, String>>,
pub values: Option<IndexMap<String, String>>,
pub path: String,
}

View File

@ -1,4 +1,5 @@
use std::collections::{HashMap, HashSet};
use std::collections::HashSet;
use indexmap::IndexMap;
pub mod context;
pub mod error;
@ -47,6 +48,7 @@ impl Validator {
let schema_opt = self.db.schemas.get(schema_id);
if let Some(schema) = schema_opt {
let response = schema_id.ends_with(".response");
let ctx = ValidationContext::new(
&self.db,
&schema,
@ -55,6 +57,7 @@ impl Validator {
HashSet::new(),
false,
false,
response,
);
match ctx.validate_scoped() {
Ok(result) => {
@ -92,7 +95,7 @@ impl Validator {
} else {
Drop::with_errors(vec![Error {
code: "SCHEMA_NOT_FOUND".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("schema".to_string(), schema_id.to_string()),
])),
details: ErrorDetails {

View File

@ -1,4 +1,5 @@
use std::collections::{HashMap, HashSet};
use std::collections::HashSet;
use indexmap::IndexMap;
use serde_json::Value;
@ -18,7 +19,7 @@ impl<'a> ValidationContext<'a> {
{
result.errors.push(ValidationError {
code: "MIN_ITEMS_VIOLATED".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("count".to_string(), arr.len().to_string()),
("limit".to_string(), min.to_string()),
])),
@ -30,7 +31,7 @@ impl<'a> ValidationContext<'a> {
{
result.errors.push(ValidationError {
code: "MAX_ITEMS_VIOLATED".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("count".to_string(), arr.len().to_string()),
("limit".to_string(), max.to_string()),
])),
@ -77,7 +78,7 @@ impl<'a> ValidationContext<'a> {
if _match_count < min {
result.errors.push(ValidationError {
code: "CONTAINS_VIOLATED".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("count".to_string(), _match_count.to_string()),
("limit".to_string(), min.to_string()),
])),
@ -89,7 +90,7 @@ impl<'a> ValidationContext<'a> {
{
result.errors.push(ValidationError {
code: "CONTAINS_VIOLATED".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("count".to_string(), _match_count.to_string()),
("limit".to_string(), max.to_string()),
])),

View File

@ -1,4 +1,4 @@
use std::collections::HashMap;
use indexmap::IndexMap;
use crate::validator::Validator;
use crate::validator::context::ValidationContext;
@ -19,7 +19,7 @@ impl<'a> ValidationContext<'a> {
if !Validator::check_type(t, current) {
result.errors.push(ValidationError {
code: "INVALID_TYPE".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("expected".to_string(), t.to_string()),
])),
path: self.path.to_string(),
@ -37,7 +37,7 @@ impl<'a> ValidationContext<'a> {
if !valid {
result.errors.push(ValidationError {
code: "INVALID_TYPE".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("expected".to_string(), format!("{:?}", types)),
])),
path: self.path.to_string(),
@ -51,7 +51,7 @@ impl<'a> ValidationContext<'a> {
if !equals(current, const_val) {
result.errors.push(ValidationError {
code: "CONST_VIOLATED".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("expected".to_string(), format!("{:?}", const_val)),
])),
path: self.path.to_string(),
@ -74,7 +74,7 @@ impl<'a> ValidationContext<'a> {
if !found {
result.errors.push(ValidationError {
code: "ENUM_MISMATCH".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("expected".to_string(), format!("{:?}", enum_vals)),
])),
path: self.path.to_string(),

View File

@ -1,4 +1,4 @@
use std::collections::HashMap;
use indexmap::IndexMap;
use crate::database::object::{is_primitive_type, SchemaTypeOrArray};
use crate::database::schema::Schema;
@ -75,7 +75,7 @@ impl<'a> ValidationContext<'a> {
if !result.evaluated_keys.contains(key) && !self.overrides.contains(key) {
result.errors.push(ValidationError {
code: "STRICT_PROPERTY_VIOLATION".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("property_name".to_string(), key.to_string()),
])),
path: self.join_path(key),

View File

@ -1,4 +1,4 @@
use std::collections::HashMap;
use indexmap::IndexMap;
use crate::validator::context::ValidationContext;
use crate::validator::error::ValidationError;
@ -21,7 +21,7 @@ impl<'a> ValidationContext<'a> {
if should && let Err(e) = f(current) {
result.errors.push(ValidationError {
code: "FORMAT_MISMATCH".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("format".to_string(), self.schema.format.clone().unwrap_or_default()),
("error".to_string(), e.to_string()),
])),
@ -35,7 +35,7 @@ impl<'a> ValidationContext<'a> {
{
result.errors.push(ValidationError {
code: "FORMAT_MISMATCH".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("format".to_string(), self.schema.format.clone().unwrap_or_default()),
])),
path: self.path.to_string(),

View File

@ -1,7 +1,7 @@
use crate::validator::context::ValidationContext;
use crate::validator::error::ValidationError;
use crate::validator::result::ValidationResult;
use std::collections::HashMap;
use indexmap::IndexMap;
pub mod array;
pub mod cases;
@ -64,7 +64,7 @@ impl<'a> ValidationContext<'a> {
if self.depth > 100 {
Err(ValidationError {
code: "RECURSION_LIMIT_EXCEEDED".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("limit".to_string(), 100.to_string()),
])),
path: self.path.to_string(),

View File

@ -1,4 +1,4 @@
use std::collections::HashMap;
use indexmap::IndexMap;
use crate::validator::context::ValidationContext;
use crate::validator::error::ValidationError;
@ -16,7 +16,7 @@ impl<'a> ValidationContext<'a> {
{
result.errors.push(ValidationError {
code: "MINIMUM_VIOLATED".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("value".to_string(), num.to_string()),
("limit".to_string(), min.to_string()),
])),
@ -28,7 +28,7 @@ impl<'a> ValidationContext<'a> {
{
result.errors.push(ValidationError {
code: "MAXIMUM_VIOLATED".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("value".to_string(), num.to_string()),
("limit".to_string(), max.to_string()),
])),
@ -40,7 +40,7 @@ impl<'a> ValidationContext<'a> {
{
result.errors.push(ValidationError {
code: "EXCLUSIVE_MINIMUM_VIOLATED".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("value".to_string(), num.to_string()),
("limit".to_string(), ex_min.to_string()),
])),
@ -52,7 +52,7 @@ impl<'a> ValidationContext<'a> {
{
result.errors.push(ValidationError {
code: "EXCLUSIVE_MAXIMUM_VIOLATED".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("value".to_string(), num.to_string()),
("limit".to_string(), ex_max.to_string()),
])),
@ -64,7 +64,7 @@ impl<'a> ValidationContext<'a> {
if (val - val.round()).abs() > f64::EPSILON {
result.errors.push(ValidationError {
code: "MULTIPLE_OF_VIOLATED".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("value".to_string(), num.to_string()),
("multiple_of".to_string(), multiple_of.to_string()),
])),

View File

@ -1,4 +1,5 @@
use std::collections::{HashMap, HashSet};
use std::collections::HashSet;
use indexmap::IndexMap;
use serde_json::Value;
@ -38,7 +39,7 @@ impl<'a> ValidationContext<'a> {
} else {
result.errors.push(ValidationError {
code: "CONST_VIOLATED".to_string(), // Aligning with original const override errors natively
values: Some(HashMap::from([
values: Some(IndexMap::from([
("value".to_string(), type_str.to_string()),
])),
path: self.join_path("type"),
@ -49,7 +50,7 @@ impl<'a> ValidationContext<'a> {
// Because it's a global entity target, the payload must structurally provide a discriminator natively
result.errors.push(ValidationError {
code: "MISSING_TYPE".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("expected".to_string(), expected_type.to_string()),
])),
path: self.path.clone(), // Empty boundary
@ -104,7 +105,7 @@ impl<'a> ValidationContext<'a> {
{
result.errors.push(ValidationError {
code: "MIN_PROPERTIES_VIOLATED".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("count".to_string(), obj.len().to_string()),
("limit".to_string(), min.to_string()),
])),
@ -117,7 +118,7 @@ impl<'a> ValidationContext<'a> {
{
result.errors.push(ValidationError {
code: "MAX_PROPERTIES_VIOLATED".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("count".to_string(), obj.len().to_string()),
("limit".to_string(), max.to_string()),
])),
@ -131,7 +132,7 @@ impl<'a> ValidationContext<'a> {
if field == "type" {
result.errors.push(ValidationError {
code: "MISSING_TYPE".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("field_name".to_string(), "type".to_string()),
])),
path: self.join_path(field),
@ -139,7 +140,7 @@ impl<'a> ValidationContext<'a> {
} else {
result.errors.push(ValidationError {
code: "REQUIRED_FIELD_MISSING".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("field_name".to_string(), field.to_string()),
])),
path: self.join_path(field),
@ -158,7 +159,7 @@ impl<'a> ValidationContext<'a> {
if !obj.contains_key(req_prop) {
result.errors.push(ValidationError {
code: "DEPENDENCY_MISSING".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("property_name".to_string(), prop.to_string()),
("required_property".to_string(), req_prop.to_string()),
])),
@ -178,6 +179,34 @@ impl<'a> ValidationContext<'a> {
}
}
if !self.response {
if let Some(compiled_props) = self.schema.compiled_properties.get() {
for (key, sub_schema) in compiled_props {
if sub_schema.is_immutable() && obj.contains_key(key) {
result.errors.push(ValidationError {
code: "IMMUTABLE_PROPERTY_VIOLATION".to_string(),
values: Some(IndexMap::from([
("property_name".to_string(), key.to_string()),
])),
path: self.join_path(key),
});
}
}
} else if let Some(props) = &self.schema.properties {
for (key, sub_schema) in props {
if sub_schema.is_immutable() && obj.contains_key(key) {
result.errors.push(ValidationError {
code: "IMMUTABLE_PROPERTY_VIOLATION".to_string(),
values: Some(IndexMap::from([
("property_name".to_string(), key.to_string()),
])),
path: self.join_path(key),
});
}
}
}
}
if let Some(props) = &self.schema.properties {
for (key, sub_schema) in props {
if self.overrides.contains(key) {
@ -227,6 +256,7 @@ impl<'a> ValidationContext<'a> {
HashSet::new(),
self.extensible,
self.reporter,
self.response,
);
result.merge(ctx.validate()?);

View File

@ -1,5 +1,3 @@
use std::collections::HashMap;
use crate::validator::context::ValidationContext;
use crate::validator::error::ValidationError;
use crate::validator::result::ValidationResult;
@ -55,10 +53,10 @@ impl<'a> ValidationContext<'a> {
return self.execute_polymorph(options, result);
} else {
result.errors.push(ValidationError {
code: "UNCOMPILED_ONEOF".to_string(),
values: None,
path: self.path.to_string(),
});
code: "UNCOMPILED_ONEOF".to_string(),
values: None,
path: self.path.to_string(),
});
return Ok(false);
}
}
@ -110,9 +108,10 @@ impl<'a> ValidationContext<'a> {
} else {
result.errors.push(ValidationError {
code: "MISSING_COMPILED_SCHEMA".to_string(),
values: Some(HashMap::from([
("target_id".to_string(), target_id.to_string()),
])),
values: Some(IndexMap::from([(
"target_id".to_string(),
target_id.to_string(),
)])),
path: self.path.to_string(),
});
return Ok(false);
@ -132,9 +131,7 @@ impl<'a> ValidationContext<'a> {
} else {
result.errors.push(ValidationError {
code: "MISSING_COMPILED_SCHEMA".to_string(),
values: Some(HashMap::from([
("index".to_string(), idx.to_string()),
])),
values: Some(IndexMap::from([("index".to_string(), idx.to_string())])),
path: self.path.to_string(),
});
return Ok(false);
@ -144,14 +141,12 @@ impl<'a> ValidationContext<'a> {
}
} else {
let values = if let Some(d) = self.schema.compiled_discriminator.get() {
Some(HashMap::from([
Some(IndexMap::from([
("discriminator".to_string(), d.to_string()),
("value".to_string(), val.to_string()),
]))
} else {
Some(HashMap::from([
("primitive".to_string(), val.to_string()),
]))
Some(IndexMap::from([("primitive".to_string(), val.to_string())]))
};
result.errors.push(ValidationError {
code: if self.schema.family.is_some() {
@ -168,9 +163,10 @@ impl<'a> ValidationContext<'a> {
if let Some(d) = self.schema.compiled_discriminator.get() {
result.errors.push(ValidationError {
code: "MISSING_TYPE".to_string(),
values: Some(HashMap::from([
("discriminator".to_string(), d.to_string()),
])),
values: Some(IndexMap::from([(
"discriminator".to_string(),
d.to_string(),
)])),
path: self.path.to_string(),
});
}

View File

@ -1,4 +1,4 @@
use std::collections::HashMap;
use indexmap::IndexMap;
use crate::validator::context::ValidationContext;
use crate::validator::error::ValidationError;
@ -17,7 +17,7 @@ impl<'a> ValidationContext<'a> {
{
result.errors.push(ValidationError {
code: "MIN_LENGTH_VIOLATED".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("count".to_string(), s.chars().count().to_string()),
("limit".to_string(), min.to_string()),
])),
@ -29,7 +29,7 @@ impl<'a> ValidationContext<'a> {
{
result.errors.push(ValidationError {
code: "MAX_LENGTH_VIOLATED".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("count".to_string(), s.chars().count().to_string()),
("limit".to_string(), max.to_string()),
])),
@ -40,7 +40,7 @@ impl<'a> ValidationContext<'a> {
if !compiled_re.0.is_match(s) {
result.errors.push(ValidationError {
code: "PATTERN_VIOLATED".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("pattern".to_string(), self.schema.pattern.clone().unwrap_or_default()),
("value".to_string(), s.to_string()),
])),
@ -53,7 +53,7 @@ impl<'a> ValidationContext<'a> {
{
result.errors.push(ValidationError {
code: "PATTERN_VIOLATED".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("pattern".to_string(), pattern.to_string()),
("value".to_string(), s.to_string()),
])),

View File

@ -2,7 +2,7 @@ use crate::database::object::{is_primitive_type, SchemaTypeOrArray};
use crate::validator::context::ValidationContext;
use crate::validator::error::ValidationError;
use crate::validator::result::ValidationResult;
use std::collections::HashMap;
use indexmap::IndexMap;
impl<'a> ValidationContext<'a> {
pub(crate) fn validate_type(
@ -76,7 +76,7 @@ impl<'a> ValidationContext<'a> {
if !resolved {
result.errors.push(ValidationError {
code: "DYNAMIC_TYPE_RESOLUTION_FAILED".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("pointer".to_string(), t.clone()),
("discriminator".to_string(), var_name.to_string()),
])),
@ -109,7 +109,7 @@ impl<'a> ValidationContext<'a> {
if t.starts_with('$') {
result.errors.push(ValidationError {
code: "DYNAMIC_TYPE_RESOLUTION_FAILED".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("pointer".to_string(), target_id.to_string()),
])),
path: self.path.to_string(),
@ -117,7 +117,7 @@ impl<'a> ValidationContext<'a> {
} else if self.schema.is_proxy() {
result.errors.push(ValidationError {
code: "PROXY_TYPE_RESOLUTION_FAILED".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("pointer".to_string(), target_id.to_string()),
])),
path: self.path.to_string(),
@ -125,7 +125,7 @@ impl<'a> ValidationContext<'a> {
} else {
result.errors.push(ValidationError {
code: "INHERITANCE_RESOLUTION_FAILED".to_string(),
values: Some(HashMap::from([
values: Some(IndexMap::from([
("pointer".to_string(), target_id.to_string()),
])),
path: self.path.to_string(),

View File

@ -1 +1 @@
1.0.168
1.0.184