diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index 221603c5b4b..9a845b22f04 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -680,7 +680,7 @@ The check runs where the JSON schema validation of a document's properties runs, ## Property Constraints (`propertyConstraints`) -Protocol version 14 adds the doctype-level `propertyConstraints` keyword: named rules the integer properties of every created or replaced document must meet, where JSON Schema can only bound one property at a time. Each rule is a condition: a comparison of two integer expressions, or `anyOf`, `allOf` or `not` over conditions. +Protocol version 14 adds the doctype-level `propertyConstraints` keyword: named rules the properties of every created or replaced document must meet, where JSON Schema can only bound one property at a time. Each rule is a condition: a comparison of two integer expressions, a test of whether the document holds a property, or `anyOf`, `allOf` or `not` over conditions. ```json "propertyConstraints": { @@ -696,13 +696,17 @@ Protocol version 14 adds the doctype-level `propertyConstraints` keyword: named }, "feeWaivedOrAtLeastTen": { "anyOf": [{ "equal": ["fee", 0] }, { "greaterThanOrEqual": ["fee", 10] }] + }, + "discountGivenAboveZero": { + "anyOf": [{ "absent": "discount" }, { "greaterThan": ["discount", 0] }] } } ``` -The first rule reads `((price + fee) * quantity) <= deposit`, the last `fee == 0 || fee >= 10`. A rule's name is 1 to 64 letters, digits or underscores, and the rule is a condition, an object with one key: +The first rule reads `((price + fee) * quantity) <= deposit`, `feeWaivedOrAtLeastTen` reads `fee == 0 || fee >= 10`, and the last lets an offer leave its discount out but not give a discount of 0. A rule's name is 1 to 64 letters, digits or underscores, and the rule is a condition, an object with one key: - a comparison, `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` or `greaterThanOrEqual`, listing the left and the right expression; +- `{ "present": path }`, holding if the document holds the property, and `{ "absent": path }`, holding if it leaves it out (a property set to null counts as left out). An operand reads a property the document leaves out as 0, so only these tell "not given" from "given as 0". They may name a property of any type, an object or a member of one included, since they read no value; - `{ "anyOf": [...] }`, holding if at least one of two or more conditions holds; - `{ "allOf": [...] }`, holding if every one of two or more conditions holds; - `{ "not": condition }`, holding if its one condition does not. @@ -721,11 +725,11 @@ The arithmetic is exact over `i128`. Operands are evaluated left to right, and e Conditions are checked in declared order and no further than the outcome needs: a comparison evaluates its left side, then its right; `anyOf` stops at the first condition that holds and `allOf` at the first that fails. A fault in a condition that is checked breaks the rule whatever the others would say, and `not` does not turn it into a pass. So an earlier condition guards a later one: `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` holds for a `b` of 0 without dividing by it, while the same two conditions the other way round divide by zero and break the rule. -The parser (generation 3, meta-schema v3) checks the keyword on every parse, stored contracts included: the shape, that every path names an integer property of the type (a nested one by its dotted path) that is neither `transient` nor inside a transient object (a transient value is never stored, so a stored document could not be held to the rule), that every comparison reads at least one property (a constant one would make its rule, or an `anyOf` around it, hold for every document or for none), that no `anyOf` or `allOf` holds one of its own kind directly and no `not` a `not`, that no literal divisor is 0 and no literal exponent negative, and that no condition or operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), a constant that keeps a parse without full validation from recursing without bound and that no registrable rule comes near. Under full validation, when a contract registers or updates, it also holds the limits: at most `SystemLimits::max_property_constraints` rules per type (16) and `max_property_constraint_nodes` nodes per rule (32), counting every comparison and logical operator, every arithmetic operator and every operand, and that no `anyOf` or `allOf` lists the same condition twice (conditions that parse alike, so `1` and `1.0` are the same value). The rules are fixed when the document type is created: adding, removing or changing one is an incompatible schema change (`IncompatibleDocumentTypeSchemaError`, 10246), since stored documents were judged against the rules as they were. +The parser (generation 3, meta-schema v3) checks the keyword on every parse, stored contracts included: the shape, that every path an operand reads names an integer property of the type (a nested one by its dotted path) and every path `present` or `absent` tests names a property of the type, and that neither is `transient` nor inside a transient object (a transient value is never stored, so a stored document could not be held to the rule), that every comparison reads at least one property (a constant one would make its rule, or an `anyOf` around it, hold for every document or for none), that no `anyOf` or `allOf` holds one of its own kind directly and no `not` a `not`, that no literal divisor is 0 and no literal exponent negative, and that no condition or operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), a constant that keeps a parse without full validation from recursing without bound and that no registrable rule comes near. Under full validation, when a contract registers or updates, it also holds the limits: at most `SystemLimits::max_property_constraints` rules per type (16) and `max_property_constraint_nodes` nodes per rule (32), counting every comparison and logical operator, every `present` or `absent`, every arithmetic operator and every operand, and that no `anyOf` or `allOf` lists the same condition twice (conditions that parse alike, so `1` and `1.0` are the same value). The rules are fixed when the document type is created: adding, removing or changing one is an incompatible schema change (`IncompatibleDocumentTypeSchemaError`, 10246), since stored documents were judged against the rules as they were. Enforcement lives in `DataContract::validate_document_properties` (generation 0, extended in place: the call is inert before protocol version 14, where `validate_property_constraints` is `None`), after the schema validation. Document create and replace structure validation call it, so consensus applies the rules, and so does every client that validates a document before sending it. The rules are checked in name order against the document's properties, which for a replace is the whole document, and the first one broken fails with `DocumentPropertyConstraintViolatedError` (basic code 10422), naming the document type, the rule and why: the rule does not hold, or evaluating it overflowed, divided by zero, raised to a negative power or read a value that is not an integer. The check reads no state and changes nothing stored, so it adds no fee; the limits bound its cost. Transfers, purchases and price updates change no property and are not judged. -In Rust the rules are `DocumentTypeV2Getters::property_constraints` (a map from name to `PropertyConstraint`, a comparison or an `anyOf`, `allOf` or `not` of them, empty on types that predate the keyword), each rule's `holds` and `violation` evaluate it against a document's data, and the document check is `DocumentTypeV0Methods::validate_property_constraints`. +In Rust the rules are `DocumentTypeV2Getters::property_constraints` (a map from name to `PropertyConstraint`: a comparison, a `present` or `absent`, or an `anyOf`, `allOf` or `not` of them, empty on types that predate the keyword; `property_reads` lists what a rule reads and whether by value or by presence), each rule's `holds` and `violation` evaluate it against a document's data, and the document check is `DocumentTypeV0Methods::validate_property_constraints`. ## Rules and Guidelines diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index 85bc7e07571..dc2dc9fa9a0 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -399,7 +399,7 @@ try { ## Property constraints (`propertyConstraints`) -From protocol version 14 a document type can declare rules its documents' integer properties must meet, each a comparison of two integer expressions built from property paths and integer values, or `anyOf`, `allOf` or `not` over such conditions: +From protocol version 14 a document type can declare rules its documents' properties must meet, each a comparison of two integer expressions built from property paths and integer values, a `present` or `absent` test, or `anyOf`, `allOf` or `not` over such conditions: ```json "propertyConstraints": { @@ -414,11 +414,14 @@ From protocol version 14 a document type can declare rules its documents' intege }, "feeWaivedOrAtLeastTen": { "anyOf": [{ "equal": ["fee", 0] }, { "greaterThanOrEqual": ["fee", 10] }] + }, + "discountGivenAboveZero": { + "anyOf": [{ "absent": "discount" }, { "greaterThan": ["discount", 0] }] } } ``` -The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. +The comparisons are `equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan` and `greaterThanOrEqual`, and the operators `add` and `multiply` (two or more operands) and `subtract`, `divide`, `modulo` and `power` (exactly two). `anyOf` holds if at least one of two or more conditions holds, `allOf` if every one does, and `not` if its one condition does not; conditions are checked in order and `anyOf` stops at the first that holds, so `{ "anyOf": [{ "equal": ["b", 0] }, { "equal": [{ "divide": ["a", "b"] }, 2] }] }` never divides by zero. A property the document leaves out counts as 0, or as the value of an `ifAbsent` operand naming it; `{ "present": path }` and `{ "absent": path }` tell a property left out from one set to 0, and may name a property of any type. The arithmetic is exact over 128-bit integers, and `divide` and `modulo` are Euclidean, so a remainder is never negative. The rules are fixed when the document type is created. Consensus checks every rule on each create and replace, and rejects a document that breaks one, or whose rule overflows, divides by zero or raises to a negative power. The code reaches JS as `error.code`, and the message names the rule: diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index 8fdc957f045..572394a9ba3 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/dashpay/platform/blob/master/packages/rs-dpp/schema/meta_schemas/document/v1/document-meta.json", - "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property), declaring one target or a reference expression of anyOf and allOf, and its ownerRefersTo and creatorRefersTo forms on the document type, whose value is the writer or the creator (an identity, a permanentDocument lookup, or an expression of them), the requiredSince property keyword (the contract version a property is required from), the propertyConstraints doctype keyword (named conditions over the document's integer properties, comparisons between integer expressions combined with anyOf, allOf and not, which every created or replaced document must meet), the maxBytes property keyword (the most UTF-8 bytes a string, or each string element of a typed array, may take), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", + "$comment": "EDITABLE UNTIL 4.2 (PROTOCOL V14) IS LIVE ON MAINNET; FROZEN AFTER. This v3 document meta-schema activates with protocol v14 (CONTRACT_VERSIONS_V6). It is v2 plus the ranked index keywords (rankedCountable, rankedSummable, rankedAverageable), the refersTo reference keyword on identifier properties (and, for an identityPublicKey reference with identityProperty, on the key id integer property), declaring one target or a reference expression of anyOf and allOf, and its ownerRefersTo and creatorRefersTo forms on the document type, whose value is the writer or the creator (an identity, a permanentDocument lookup, or an expression of them), the requiredSince property keyword (the contract version a property is required from), the propertyConstraints doctype keyword (named conditions over the document's properties, comparisons between integer expressions and present or absent tests combined with anyOf, allOf and not, which every created or replaced document must meet), the maxBytes property keyword (the most UTF-8 bytes a string, or each string element of a typed array, may take), the timeRange index transform, and typed arrays (an array property whose items schema names one scalar element type instead of byteArray, stored inline as an element count followed by the elements, whose identifier elements may carry a refersTo), refuses `-` in property and document type names (word characters only; a census of every contract on mainnet and testnet found none), and admits every v14+ contract written to disk. v2 stays in place for protocol v13, where those keys still fail an index entry's `additionalProperties: false`. Once 4.2 is live on mainnet, mutating it would change historical validation results and break consensus replay, and any new top-level property or rule MUST go in a newer meta-schema version (v4+). The $id above deliberately still names the v1 path: v1, v2 and v3 all share that identity, and it is the exact string `enrich_with_base_schema` injects as every PV12+ document schema's `$schema`, so bumping it here would be a wire-visible change rather than a documentation fix.", "type": "object", "$defs": { "referenceOperands": { @@ -40,7 +40,7 @@ } }, "propertyConstraint": { - "description": "A rule of propertyConstraints, or a condition inside one: an object with one key, either a comparison listing the two integer expressions it compares, left then right, or anyOf (at least one of its conditions holds), allOf (every one of its conditions holds) or not (its one condition does not hold)", + "description": "A rule of propertyConstraints, or a condition inside one: an object with one key, either a comparison listing the two integer expressions it compares, left then right, present or absent naming a property (the document holds it, or leaves it out), or anyOf (at least one of its conditions holds), allOf (every one of its conditions holds) or not (its one condition does not hold)", "type": "object", "properties": { "equal": { @@ -61,6 +61,14 @@ "greaterThanOrEqual": { "$ref": "#/$defs/propertyConstraintOperandPair" }, + "present": { + "description": "Holds if the document holds the property at this path, of any type, an object included, with a value other than null. Unlike an operand, which reads a property the document leaves out as 0, it tells a property left out from one set to 0", + "$ref": "#/$defs/propertyConstraintPath" + }, + "absent": { + "description": "Holds if the document leaves the property at this path out, or sets it to null", + "$ref": "#/$defs/propertyConstraintPath" + }, "anyOf": { "description": "Holds if at least one of its conditions holds, checked in declared order and stopping at the first that holds: two or more conditions, no two alike, none of them directly an anyOf (it says what one flat list says)", "$ref": "#/$defs/propertyConstraintConditions", @@ -157,7 +165,7 @@ } }, "propertyConstraintPath": { - "description": "The dotted path of an integer property of the document type, a nested one through the objects around it", + "description": "The dotted path of a property of the document type, a nested one through the objects around it: an integer property when an operand reads its value, any property when present or absent tests it", "type": "string", "pattern": "^[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*$" }, @@ -2024,7 +2032,7 @@ } }, "propertyConstraints": { - "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer property of the document type, whose value it takes, 0 when the document leaves it out, or an object with one key: ifAbsent, a property path and the integer value it takes when the document leaves it out; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property a rule reads must be an integer property that is neither transient nor inside a transient object, every comparison must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every arithmetic operator and every operand; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", + "description": "Rules every created or replaced document of the type must meet, by name (1 to 64 letters, digits or underscores). A rule is a condition: an object with one key, either a comparison (equal, notEqual, lessThan, lessThanOrEqual, greaterThan or greaterThanOrEqual) listing the two integer expressions it compares, left then right, present or absent naming a property of any type (the document holds it, or leaves it out or sets it to null), or anyOf, allOf or not over conditions: anyOf holds if at least one of its two or more conditions holds, allOf if every one does, not if its one condition does not. An expression is an integer value, the dotted path of an integer property of the document type, whose value it takes, 0 when the document leaves it out, or an object with one key: ifAbsent, a property path and the integer value it takes when the document leaves it out; add or multiply, two or more operands; subtract, divide, modulo or power, exactly two. The arithmetic is exact over 128-bit signed integers, operands evaluated left to right: divide and modulo are Euclidean (the remainder is never negative), 0 to the power 0 is 1, and a value or intermediate result that does not fit, a zero divisor, a negative exponent or a property value that is not an integer breaks the rule. Conditions are checked in declared order and no further than the outcome needs (anyOf stops at the first that holds, allOf at the first that fails), and a fault met in a condition that is checked breaks the rule whatever the others would say (not does not turn it into a pass), so an earlier condition can guard a later one. Every property an operand reads must be an integer property, present and absent may test a property of any type, and no property a rule reads may be transient or inside a transient object; every comparison must read at least one property, an anyOf or allOf may not hold two alike conditions or directly another of its kind, a not may not hold directly another not, a literal 0 divisor or negative exponent is refused, and no condition or operand may nest deeper than 64 levels; a type declares at most SystemLimits max_property_constraints rules (16 from protocol version 14) of at most max_property_constraint_nodes nodes each (32), counting every comparison and logical operator, every present or absent, every arithmetic operator and every operand; all checked at contract registration. When a document is created or replaced, consensus checks every rule, in name order, after the schema validation, and refuses the first one the document breaks (DocumentPropertyConstraintViolatedError, 10422). The rules read no state and change nothing stored. Fixed when the document type is created: adding, removing or changing a rule is an incompatible schema change on update. Available from protocol version 14.", "type": "object", "propertyNames": { "pattern": "^[a-zA-Z0-9_]{1,64}$" diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index 8c0d0383217..7d882b21a83 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -1,7 +1,9 @@ use crate::data_contract::config::DataContractConfig; use crate::data_contract::document_type::class_methods::apply_required_since::apply_required_since; use crate::data_contract::document_type::class_methods::parse_typed_array::parse_typed_array; -use crate::data_contract::document_type::property_constraints::parse_property_constraints; +use crate::data_contract::document_type::property_constraints::{ + parse_property_constraints, PropertyRead, +}; use crate::data_contract::document_type::reference_lookup::{ MAX_LOOKUP_INDEX_NAME_LENGTH, MAX_LOOKUP_KEYS, MAX_LOOKUP_PATH_LENGTH, }; @@ -1870,11 +1872,12 @@ pub(super) fn validate_encrypted_for_declarations( } /// Reads the `propertyConstraints` keyword onto the document type and checks -/// every property its rules read: an integer property of the type (a nested -/// one named by its dotted path, as the flattened map names it) that is -/// neither transient nor inside a transient object. A transient value is never -/// stored, so a stored document could not be held to a rule reading one. The -/// declaration's shape ([`parse_property_constraints`]) and these reads are +/// every property its rules read: by its value, an integer property of the +/// type (a nested one named by its dotted path, as the flattened map names +/// it); by its presence, a property of any type, an object included; either +/// way one that is neither transient nor inside a transient object. A +/// transient value is never stored, so a stored document could not be held to +/// a rule reading one. The declaration's shape ([`parse_property_constraints`]) and these reads are /// checked on every parse; under full validation, the limits too: at most /// `SystemLimits::max_property_constraints` rules, each of at most /// `max_property_constraint_nodes` nodes, and no `anyOf` or `allOf` listing @@ -1912,6 +1915,23 @@ pub(super) fn apply_property_constraints( } } +/// The property at the dotted `path` of `properties`, an object or a member of +/// one included, `None` when the path names none. +fn property_at_path<'a>( + properties: &'a IndexMap, + path: &str, +) -> Option<&'a DocumentProperty> { + let mut segments = path.split('.'); + let mut property = properties.get(segments.next()?)?; + for segment in segments { + let DocumentPropertyType::Object(members) = &property.property_type else { + return None; + }; + property = members.get(segment)?; + } + Some(property) +} + fn apply_property_constraints_v0( document_type: &mut DocumentTypeV2, document_type_name: &str, @@ -1926,38 +1946,53 @@ fn apply_property_constraints_v0( }; for (name, constraint) in &constraints { - for path in constraint.property_paths() { - match document_type - .flattened_properties - .get(path) - .map(|property| &property.property_type) - { - // `is_integer` leaves out the 128-bit types, which the arithmetic holds too - Some(property_type) - if property_type.is_integer() - || matches!( - property_type, - DocumentPropertyType::U128 | DocumentPropertyType::I128 - ) => {} - Some(other) => { - return Err(structure_error(format!( - "rule \"{name}\" reads \"{path}\", which has type {}, not integer", - other.name() - ))); - } - // An object is not in the flattened map either: only its members hold values - None => { - return Err(structure_error(format!( - "rule \"{name}\" reads \"{path}\", which is not an integer property of \ - the document type (a nested one is named by its dotted path)" - ))); + for (path, read) in constraint.property_reads() { + let reads = match read { + PropertyRead::Value => "reads", + PropertyRead::Presence => "tests the presence of", + }; + match read { + PropertyRead::Value => match document_type + .flattened_properties + .get(path) + .map(|property| &property.property_type) + { + // `is_integer` leaves out the 128-bit types, which the arithmetic holds too + Some(property_type) + if property_type.is_integer() + || matches!( + property_type, + DocumentPropertyType::U128 | DocumentPropertyType::I128 + ) => {} + Some(other) => { + return Err(structure_error(format!( + "rule \"{name}\" reads \"{path}\", which has type {}, not integer", + other.name() + ))); + } + // An object is not in the flattened map either: only its members hold values + None => { + return Err(structure_error(format!( + "rule \"{name}\" reads \"{path}\", which is not an integer property \ + of the document type (a nested one is named by its dotted path)" + ))); + } + }, + PropertyRead::Presence => { + if property_at_path(&document_type.properties, path).is_none() { + return Err(structure_error(format!( + "rule \"{name}\" tests the presence of \"{path}\", which is not a \ + property of the document type (a nested one is named by its dotted \ + path)" + ))); + } } } if is_transient(DocumentTypeRef::V2(document_type), path) { return Err(structure_error(format!( - "rule \"{name}\" reads \"{path}\", which is transient or inside a transient \ - object: a transient value is never stored, so a stored document could not \ - be held to the rule" + "rule \"{name}\" {reads} \"{path}\", which is transient or inside a \ + transient object: a transient value is never stored, so a stored document \ + could not be held to the rule" ))); } } diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs index 0be5b4b9164..cb2863ee717 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/property_constraints_tests.rs @@ -189,6 +189,83 @@ fn should_parse_combined_conditions_and_check_every_property_they_read() { } } +/// A system property is not a property of the type: the meta-schema refuses +/// its `$` when registering, and the parser the path when reading. +#[test] +fn should_refuse_a_presence_test_of_a_system_property() { + let rules = json!({ "rule": { "present": "$ownerId" } }); + let registered = parse_order(rules.clone(), true); + assert!( + registered.as_ref().is_err_and(is_json_schema_error), + "the meta-schema should refuse it, got {registered:?}" + ); + expect_structure_error( + parse_order(rules, false), + "tests the presence of \"$ownerId\", which is not a property of the document type", + ); +} + +/// `present` and `absent` test a property of any type, an object included, on +/// both paths; the path must name a property of the type, and not a transient +/// one. +#[test] +fn should_test_the_presence_of_any_property_the_type_declares() { + for path in [ + "note", + "ratio", + "counts", + "meta", + "meta.tag", + "meta.total", + "price", + ] { + let rules = json!({ + "rule": { "anyOf": [{ "present": path }, { "absent": "fee" }] } + }); + for full_validation in [true, false] { + let document_type = parse_order(rules.clone(), full_validation).unwrap_or_else(|e| { + panic!("{path}, full_validation {full_validation}: should parse: {e}") + }); + assert_eq!( + document_type.property_constraints()["rule"].property_paths(), + [path, "fee"] + ); + } + } + + for path in ["missing", "meta.missing", "note.length", "price.value"] { + for full_validation in [true, false] { + expect_structure_error( + parse_order(json!({ "rule": { "present": path } }), full_validation), + &format!( + "rule \"rule\" tests the presence of \"{path}\", which is not a property of \ + the document type" + ), + ); + } + } + + for (transient, path) in [("note", "note"), ("meta", "meta"), ("meta", "meta.tag")] { + let schema = order_schema( + Some(json!({ "rule": { "not": { "absent": path } } })), + Some(transient), + ); + for full_validation in [true, false] { + expect_structure_error( + parse_dispatched( + schema_value(schema.clone()), + PlatformVersion::latest(), + full_validation, + ), + &format!( + "rule \"rule\" tests the presence of \"{path}\", which is transient or \ + inside a transient object" + ), + ); + } + } +} + /// Only an integer property's value is a number the rule can compute with: a /// string, a float, an array, an object and a system property are refused on /// both paths, as is a path naming nothing. @@ -475,6 +552,10 @@ fn should_check_the_grammar_with_the_meta_schema_and_the_parser() { }), json!({ "rule": { "not": { "not": { "equal": ["price", 1] } } } }), json!({ "rule": { "anyOf": [{ "equal": ["price", 1] }, { "equal": ["price"] }] } }), + json!({ "rule": { "present": 1 } }), + json!({ "rule": { "absent": ["note"] } }), + json!({ "rule": { "present": "note", "absent": "fee" } }), + json!({ "rule": { "not": { "present": { "add": ["price", 1] } } } }), ] { let registered = parse_order(rules.clone(), true); assert!( diff --git a/packages/rs-dpp/src/data_contract/document_type/mod.rs b/packages/rs-dpp/src/data_contract/document_type/mod.rs index 08670000d26..5047d38c327 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -117,9 +117,9 @@ pub(crate) mod property_names { /// See `parse_doctype_reference` in `try_from_schema`. pub const CREATOR_REFERS_TO: &str = "creatorRefersTo"; /// Doctype-level object of named rules, each a condition on the document's - /// integer properties (a comparison of two integer expressions, or an - /// `anyOf`, `allOf` or `not` of conditions) that every created or replaced - /// document must meet. Meta-schema v3+ (protocol version 14). See + /// properties (a comparison of two integer expressions, a `present` or + /// `absent` test, or an `anyOf`, `allOf` or `not` of conditions) that every + /// created or replaced document must meet. Meta-schema v3+ (protocol version 14). See /// `parse_property_constraints` in `property_constraints`. pub const PROPERTY_CONSTRAINTS: &str = "propertyConstraints"; pub const DISTINCT_FROM: &str = "distinctFrom"; diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs index ceb489787dc..20517ba9d30 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/mod.rs @@ -1,7 +1,8 @@ //! The doctype-level `propertyConstraints` keyword (meta-schema v3, protocol //! version 14): named rules every document of the type must meet, each a -//! condition on the document's integer properties: a comparison of two integer -//! expressions, or `anyOf`, `allOf` or `not` over conditions. +//! condition on the document's properties: a comparison of two integer +//! expressions, a test of whether the document holds a property (`present`, +//! `absent`), or `anyOf`, `allOf` or `not` over conditions. //! //! ```json //! "propertyConstraints": { @@ -17,6 +18,9 @@ //! }, //! "feeWaivedOrAtLeastTen": { //! "anyOf": [{ "equal": ["fee", 0] }, { "greaterThanOrEqual": ["fee", 10] }] +//! }, +//! "discountGivenAboveZero": { +//! "anyOf": [{ "absent": "discount" }, { "greaterThan": ["discount", 0] }] //! } //! } //! ``` @@ -59,6 +63,8 @@ const POWER: &str = "power"; const ANY_OF: &str = "anyOf"; const ALL_OF: &str = "allOf"; const NOT: &str = "not"; +const PRESENT: &str = "present"; +const ABSENT: &str = "absent"; /// Every key an operand object may hold, for the errors. const OPERAND_KEYS: &str = "add, subtract, multiply, divide, modulo, power or ifAbsent"; @@ -261,30 +267,41 @@ impl ConstraintExpression { } } - /// Appends the dotted paths of the properties the expression reads to - /// `paths`, in the order it reads them. - fn collect_property_paths<'a>(&'a self, paths: &mut Vec<&'a str>) { + /// Appends the properties the expression reads, each by its value, to + /// `reads`, in the order it reads them. + fn collect_property_reads<'a>(&'a self, reads: &mut Vec<(&'a str, PropertyRead)>) { match self { ConstraintExpression::Value(_) => {} - ConstraintExpression::Property { path, .. } => paths.push(path), + ConstraintExpression::Property { path, .. } => reads.push((path, PropertyRead::Value)), ConstraintExpression::Add(operands) | ConstraintExpression::Multiply(operands) => { for operand in operands { - operand.collect_property_paths(paths); + operand.collect_property_reads(reads); } } ConstraintExpression::Subtract(left, right) | ConstraintExpression::Divide(left, right) | ConstraintExpression::Modulo(left, right) | ConstraintExpression::Power(left, right) => { - left.collect_property_paths(paths); - right.collect_property_paths(paths); + left.collect_property_reads(reads); + right.collect_property_reads(reads); } } } } +/// How a rule reads a property, which decides the properties it may name. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PropertyRead { + /// By its value, as an operand: an integer property. + Value, + /// Only whether the document holds it, in a `present` or `absent`: a + /// property of any type, an object included. + Presence, +} + /// A rule of `propertyConstraints`, or a condition inside one: a comparison of -/// two integer expressions, or `anyOf`, `allOf` or `not` over conditions. +/// two integer expressions, a test of whether the document holds a property, +/// or `anyOf`, `allOf` or `not` over conditions. #[derive(Debug, Clone, PartialEq, Eq)] pub enum PropertyConstraint { /// A comparison: the two sides must compare as `comparison` says. @@ -293,6 +310,13 @@ pub enum PropertyConstraint { left: ConstraintExpression, right: ConstraintExpression, }, + /// `present`: the document holds the property at the dotted path. One it + /// leaves out, or sets to null, is absent, as it is for an operand. Unlike + /// an operand, it tells a property left out from one set to 0, and it may + /// name a property of any type. + Present(String), + /// `absent`: the document leaves the property at the dotted path out. + Absent(String), /// `anyOf`: at least one of two or more conditions holds. AnyOf(Vec), /// `allOf`: every one of two or more conditions holds. @@ -307,10 +331,11 @@ impl PropertyConstraint { /// Evaluated left to right, and no further than the outcome needs: a /// comparison evaluates its left side, then its right one; `anyOf` checks /// its conditions in declared order and holds at the first that holds; - /// `allOf` fails at the first that fails; `not` inverts its condition. The - /// first fault an evaluated expression meets ([`ConstraintExpression::evaluate`]) - /// is returned whatever the conditions left unevaluated would say, and `not` - /// never turns a fault into a pass. So an earlier condition guards a later + /// `allOf` fails at the first that fails; `not` inverts its condition; a + /// `present` or `absent` never faults. The first fault an evaluated + /// expression meets ([`ConstraintExpression::evaluate`]) is returned + /// whatever the conditions left unevaluated would say, and `not` never + /// turns a fault into a pass. So an earlier condition guards a later /// one: `anyOf: [{ equal: ["b", 0] }, { equal: [{ divide: ["a", "b"] }, 2] }]` /// holds for a `b` of 0 without dividing by it, while the same two /// conditions the other way round divide by zero. @@ -324,6 +349,8 @@ impl PropertyConstraint { let (left, right) = (left.evaluate(data)?, right.evaluate(data)?); Ok(comparison.holds(left, right)) } + PropertyConstraint::Present(path) => Ok(is_present(data, path)), + PropertyConstraint::Absent(path) => Ok(!is_present(data, path)), PropertyConstraint::AnyOf(conditions) => { for condition in conditions { if condition.holds(data)? { @@ -357,13 +384,15 @@ impl PropertyConstraint { /// The nodes of the rule, counted against /// `SystemLimits::max_property_constraint_nodes`: every comparison and - /// logical operator, every arithmetic operator and every operand (an - /// integer value, or a property with or without `ifAbsent`). + /// logical operator, every `present` or `absent` with the property it + /// names, every arithmetic operator and every operand (an integer value, or + /// a property with or without `ifAbsent`). pub fn node_count(&self) -> usize { 1 + match self { PropertyConstraint::Compare { left, right, .. } => { left.node_count() + right.node_count() } + PropertyConstraint::Present(_) | PropertyConstraint::Absent(_) => 0, PropertyConstraint::AnyOf(conditions) | PropertyConstraint::AllOf(conditions) => { conditions.iter().map(PropertyConstraint::node_count).sum() } @@ -374,9 +403,18 @@ impl PropertyConstraint { /// The dotted paths of the properties the rule reads, in declared order, a /// path read twice listed twice. pub fn property_paths(&self) -> Vec<&str> { - let mut paths = Vec::new(); - self.collect_property_paths(&mut paths); - paths + self.property_reads() + .into_iter() + .map(|(path, _)| path) + .collect() + } + + /// The properties the rule reads, each with how it reads it, in declared + /// order, a property read twice listed twice. + pub fn property_reads(&self) -> Vec<(&str, PropertyRead)> { + let mut reads = Vec::new(); + self.collect_property_reads(&mut reads); + reads } /// Where an `anyOf` or `allOf` of the rule lists the same condition twice: @@ -395,7 +433,9 @@ impl PropertyConstraint { /// when it returns `None`. fn find_repeated_condition(&self, at: &mut String) -> Option<(String, String)> { let (key, conditions) = match self { - PropertyConstraint::Compare { .. } => return None, + PropertyConstraint::Compare { .. } + | PropertyConstraint::Present(_) + | PropertyConstraint::Absent(_) => return None, PropertyConstraint::AnyOf(conditions) => (ANY_OF, conditions), PropertyConstraint::AllOf(conditions) => (ALL_OF, conditions), PropertyConstraint::Not(condition) => { @@ -425,18 +465,21 @@ impl PropertyConstraint { None } - fn collect_property_paths<'a>(&'a self, paths: &mut Vec<&'a str>) { + fn collect_property_reads<'a>(&'a self, reads: &mut Vec<(&'a str, PropertyRead)>) { match self { PropertyConstraint::Compare { left, right, .. } => { - left.collect_property_paths(paths); - right.collect_property_paths(paths); + left.collect_property_reads(reads); + right.collect_property_reads(reads); + } + PropertyConstraint::Present(path) | PropertyConstraint::Absent(path) => { + reads.push((path, PropertyRead::Presence)) } PropertyConstraint::AnyOf(conditions) | PropertyConstraint::AllOf(conditions) => { for condition in conditions { - condition.collect_property_paths(paths); + condition.collect_property_reads(reads); } } - PropertyConstraint::Not(condition) => condition.collect_property_paths(paths), + PropertyConstraint::Not(condition) => condition.collect_property_reads(reads), } } } @@ -448,8 +491,8 @@ impl PropertyConstraint { /// The rules of the declaration's shape are checked here, on every parse: an /// object of one or more rules, each named with 1 to 64 letters, digits or /// underscores and holding one condition. A condition is an object with one -/// key: a comparison of exactly two operands, `anyOf` or `allOf` with two or -/// more conditions, none of them directly the same operator (it says what one +/// key: a comparison of exactly two operands, `present` or `absent` with a +/// property path, `anyOf` or `allOf` with two or more conditions, none of them directly the same operator (it says what one /// flat list says), or `not` with one condition that is not directly another /// `not`. An operand is an integer value, a property path, or /// an object with one key: `ifAbsent` with a path and an integer value, `add` @@ -534,7 +577,7 @@ fn single_entry(value: &Value) -> Option<(&str, &Value)> { /// Every key a condition object may hold, for the errors. fn condition_keys() -> String { format!( - "a comparison ({}), anyOf, allOf or not", + "a comparison ({}), present, absent, anyOf, allOf or not", ConstraintComparison::ALL .map(ConstraintComparison::wire_name) .join(", ") @@ -564,7 +607,8 @@ fn enter(at: &mut String, key: &str) -> usize { /// A condition at `at` (`anyOf[1]`, empty for the rule's own), where the /// errors place it, `depth` levels into its rule: an object whose one key is a -/// comparison listing its two sides, or `anyOf`, `allOf` or `not`. The error is +/// comparison listing its two sides, `present` or `absent` naming a property, +/// or `anyOf`, `allOf` or `not`. The error is /// the rest of a message naming the rule. `at` is extended for what the /// condition holds and trimmed back before a successful return. fn parse_condition( @@ -598,6 +642,17 @@ fn parse_condition( } PropertyConstraint::Not(Box::new(parse_condition(body, at, depth + 1)?)) } + // What the path names is checked against the parsed document type + PRESENT | ABSENT => { + let Some(path) = body.as_text() else { + return Err(format!("at {at} must name a property path")); + }; + if key == PRESENT { + PropertyConstraint::Present(path.to_string()) + } else { + PropertyConstraint::Absent(path.to_string()) + } + } _ => { let Some(comparison) = ConstraintComparison::ALL .into_iter() @@ -818,6 +873,15 @@ fn integer_value(value: &Value, at: &str) -> Result { }) } +/// Whether `data` holds the property at `path`: absent exactly where +/// [`property_value`] would take the `if_absent` value. +fn is_present(data: &Value, path: &str) -> bool { + matches!( + data.get_optional_value_at_path(path), + Ok(Some(value)) if !matches!(value, Value::Null) + ) +} + /// The value of the property at `path` in `data`, or `if_absent` when the /// document leaves it out. An intermediate that is not an object reads as /// absent: the schema validation that runs first refuses such a document. diff --git a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs index 70fc920d0f3..c5f1cf31b0b 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property_constraints/tests.rs @@ -595,6 +595,15 @@ fn should_find_a_condition_an_any_of_or_all_of_repeats() { }), Some(("anyOf[0].allOf[1]", "anyOf[0].allOf[0]")), ), + ( + platform_value!({ "anyOf": [{ "present": "fee" }, one.clone(), { "present": "fee" }] }), + Some(("anyOf[2]", "anyOf[0]")), + ), + // Testing the presence of a property and its absence are different conditions + ( + platform_value!({ "anyOf": [{ "present": "fee" }, { "absent": "fee" }] }), + None, + ), ] { let rule = parse_rule_value(condition.clone()); assert_eq!( @@ -605,6 +614,71 @@ fn should_find_a_condition_an_any_of_or_all_of_repeats() { } } +// ── present and absent ────────────────────────────────────────────────── + +#[test] +fn should_parse_present_and_absent() { + assert_eq!( + parse_rule_value(platform_value!({ "present": "meta.total" })), + PropertyConstraint::Present("meta.total".to_string()) + ); + assert_eq!( + parse_rule_value(platform_value!({ + "anyOf": [{ "absent": "discount" }, { "greaterThan": ["discount", 0] }] + })), + PropertyConstraint::AnyOf(vec![ + PropertyConstraint::Absent("discount".to_string()), + compare( + ConstraintComparison::GreaterThan, + property("discount"), + ConstraintExpression::Value(0) + ), + ]) + ); + + for (condition, needle) in [ + ( + platform_value!({ "present": 1 }), + "rule \"rule\" at present must name a property path", + ), + ( + platform_value!({ "absent": ["discount"] }), + "rule \"rule\" at absent must name a property path", + ), + ( + platform_value!({ "not": { "present": { "add": ["price", 1] } } }), + "rule \"rule\" at not.present must name a property path", + ), + ( + platform_value!({ "exists": "discount" }), + "rule \"rule\" names \"exists\", which is not a comparison (equal, notEqual, \ + lessThan, lessThanOrEqual, greaterThan, greaterThanOrEqual), present, absent, anyOf, \ + allOf or not", + ), + ] { + expect_refusal(platform_value!({ "rule": condition }), needle); + } +} + +/// A presence test is one node, and reads its property by presence, where an operand +/// reads one by value. +#[test] +fn should_count_a_presence_test_as_one_node_reading_by_presence() { + let rule = parse_rule_value(platform_value!({ + "anyOf": [{ "absent": "discount" }, { "greaterThan": ["discount", 0] }] + })); + // anyOf, absent discount, greaterThan, discount, 0 + assert_eq!(rule.node_count(), 5); + assert_eq!( + rule.property_reads(), + [ + ("discount", PropertyRead::Presence), + ("discount", PropertyRead::Value) + ] + ); + assert_eq!(rule.property_paths(), ["discount", "discount"]); +} + // ── evaluation ────────────────────────────────────────────────────────── #[test] @@ -968,3 +1042,44 @@ fn should_count_the_nodes_and_list_the_paths_of_combined_conditions() { assert_eq!(rule.node_count(), 8); assert_eq!(rule.property_paths(), ["a", "b", "a"]); } + +/// A property the document leaves out, or sets to null, is absent, as it is for an +/// operand; one it sets to anything else, 0 and objects included, is present. +#[test] +fn should_tell_a_property_left_out_from_one_set_to_zero() { + let values = data(&[ + ("zero", Value::U64(0)), + ("empty", Value::Null), + ("note", Value::Text("hi".to_string())), + ("meta", platform_value!({ "count": 9 })), + ("flat", Value::U8(1)), + ]); + for (path, present) in [ + ("zero", true), + ("note", true), + ("meta", true), + ("meta.count", true), + ("missing", false), + ("empty", false), + ("meta.missing", false), + // An intermediate that is not an object reads as absent + ("flat.count", false), + ] { + let present_rule = parse_rule_value(platform_value!({ "present": path })); + let absent_rule = parse_rule_value(platform_value!({ "absent": path })); + assert_eq!(present_rule.holds(&values), Ok(present), "present {path}"); + assert_eq!(absent_rule.holds(&values), Ok(!present), "absent {path}"); + } + + // Optional, but above zero when given: an operand alone reads a discount left out + // as 0, so it cannot say this + let rule = parse_rule_value(platform_value!({ + "anyOf": [{ "absent": "discount" }, { "greaterThan": ["discount", 0] }] + })); + assert_eq!(rule.violation(&data(&[])), None); + assert_eq!(rule.violation(&data(&[("discount", Value::U64(5))])), None); + assert_eq!( + rule.violation(&data(&[("discount", Value::U64(0))])), + Some(PropertyConstraintViolation::NotMet) + ); +} diff --git a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs index db4f2dc5637..9658d59b149 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v2/mod.rs @@ -168,11 +168,12 @@ pub struct DocumentTypeV2 { pub(in crate::data_contract) creator_reference: Option, /// The rules every created or replaced document must meet, by name, in the /// order they are checked (`propertyConstraints` keyword, protocol version - /// 14): each a condition on the document's integer properties, a comparison - /// of two integer expressions or an `anyOf`, `allOf` or `not` of conditions. - /// Empty on document types that declare none. The - /// parser (`apply_property_constraints`) holds every property a rule reads - /// to be an integer that is neither transient nor inside a transient object. + /// 14): each a condition on the document's properties, a comparison of two + /// integer expressions, a `present` or `absent` test, or an `anyOf`, `allOf` + /// or `not` of conditions. Empty on document types that declare none. The + /// parser (`apply_property_constraints`) holds every property an operand + /// reads to be an integer, and every property a rule reads to be neither + /// transient nor inside a transient object. pub(in crate::data_contract) property_constraints: BTreeMap, /// How many seconds after its creation (`$createdAt`) the platform deletes each /// document of the type (`ttl` keyword, protocol version 14), `None` when the diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs index 602681ed2e2..2a470b1d69f 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/property_constraints.rs @@ -1,7 +1,7 @@ //! End-to-end coverage for the `propertyConstraints` doctype keyword (protocol //! version 14): a document type names rules its documents' integer properties -//! must meet, each a comparison of two integer expressions or an `anyOf`, -//! `allOf` or `not` of such conditions. A create or replace +//! must meet, each a comparison of two integer expressions, a `present` or +//! `absent` test, or an `anyOf`, `allOf` or `not` of such conditions. A create or replace //! that breaks one is consensus-rejected with //! `DocumentPropertyConstraintViolatedError` (basic code 10422), naming the rule //! and why, and leaves the stored document untouched. A property the document @@ -38,6 +38,7 @@ mod property_constraints_tests { /// * `boostPower`: `ifAbsent(boost, 1) ^ 20 >= 1`, which overflows for a large boost /// * `depositCoversOrder`: `(price + fee) * quantity <= deposit` /// * `discountBelowPrice`: `discount < price`, an absent discount counting as 0 + /// * `discountGivenAboveZero`: `discount` is absent or above 0 /// * `feeWaivedOnlyWithDiscount`: `!(fee == 0 && discount == 0)` /// * `feeWaivedOrAtLeastTen`: `fee == 0 || fee >= 10` /// * `perUnitDeposit`: `deposit / quantity >= 1`, which divides by zero for no quantity @@ -71,6 +72,9 @@ mod property_constraints_tests { ] }, "discountBelowPrice": { "lessThan": ["discount", "price"] }, + "discountGivenAboveZero": { + "anyOf": [{ "absent": "discount" }, { "greaterThan": ["discount", 0] }] + }, "feeWaivedOnlyWithDiscount": { "not": { "allOf": [{ "equal": ["fee", 0] }, { "equal": ["discount", 0] }] } }, @@ -486,6 +490,36 @@ mod property_constraints_tests { assert_eq!(fixture.stored_offers().len(), 1); } + /// A discount may be left out, but one the offer gives must be above 0: only + /// a presence test tells the two apart, since an operand reads a discount left + /// out as 0. + #[tokio::test] + async fn should_tell_a_property_left_out_from_one_set_to_zero() { + let mut fixture = OfferFixture::new(); + + let result = fixture + .create(|document| document.set("discount", Value::U64(0))) + .await; + expect_violated( + result, + "discountGivenAboveZero", + PropertyConstraintViolation::NotMet, + ); + assert!(fixture.stored_offers().is_empty()); + + assert_matches!( + fixture.create(|_| {}).await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_matches!( + fixture + .create(|document| document.set("discount", Value::U64(10))) + .await, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(fixture.stored_offers().len(), 2); + } + #[tokio::test] async fn should_judge_a_replace_against_the_rules() { let mut fixture = OfferFixture::new(); diff --git a/packages/rs-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index cba4b6a73e1..02b0af116de 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -53,9 +53,9 @@ pub struct SystemLimits { /// version 14), the only generation that parses `propertyConstraints`, and never /// reached before. pub max_property_constraints: u16, - /// Maximum number of nodes in one `propertyConstraints` rule: every comparison and every - /// `anyOf`, `allOf` or `not`, every arithmetic operator and every operand, an integer - /// value or a property. An `ifAbsent` operand is one node, the default it gives included. Refused under full validation + /// Maximum number of nodes in one `propertyConstraints` rule: every comparison, every + /// `present` or `absent` and every `anyOf`, `allOf` or `not`, every arithmetic operator + /// and every operand, an integer value or a property. An `ifAbsent` operand is one node, the default it gives included. Refused under full validation /// only, like `max_property_constraints`. Read by document type parser generation 3 /// (protocol version 14) and never reached before. pub max_property_constraint_nodes: u16, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 0cb85e20854..8a7e4ec916d 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -1044,30 +1044,34 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// /// 39. **Property constraints**: the doctype-level `propertyConstraints` /// keyword (meta-schema v3, `parse_property_constraints` 0) names rules a -/// document's integer properties must meet, each a condition: a comparison +/// document's properties must meet, each a condition: a comparison /// (`equal`, `notEqual`, `lessThan`, `lessThanOrEqual`, `greaterThan`, /// `greaterThanOrEqual`) of two integer expressions built from integer /// literals, property paths and `add`, `subtract`, `multiply`, `divide`, -/// `modulo` and `power`, or `anyOf` or `allOf` over two or more conditions, -/// or `not` over one. A property the document leaves out counts as 0, or -/// as the value of an `ifAbsent` operand naming it. Arithmetic is exact -/// `i128`: `divide` and `modulo` are Euclidean (the remainder is never -/// negative), and an overflow, a zero divisor, a negative exponent or a -/// value that is not an integer refuses the document rather than wrapping. -/// Conditions are checked in declared order and no further than the -/// outcome needs (`anyOf` stops at the first that holds, `allOf` at the -/// first that fails), a fault in one that is checked refuses the document -/// whatever the others say, and `not` never turns a fault into a pass, so -/// an earlier condition guards a later one. The parser checks that every -/// path names an integer property that is neither transient nor inside a -/// transient object, that every comparison reads a property, that an -/// `anyOf` or `allOf` holds none directly of its own kind, that a `not` -/// holds no `not` directly, and that no condition or operand nests deeper -/// than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), on every parse, and -/// under full validation the limits `SystemLimits::max_property_constraints` -/// (16 rules) and `max_property_constraint_nodes` (32 per rule, every -/// comparison and logical operator counting as one) and that no `anyOf` or -/// `allOf` lists the same condition twice. +/// `modulo` and `power`; `present` or `absent` naming a property of any +/// type, whether the document holds it (the one way to tell a property +/// left out from one set to 0); `anyOf` or `allOf` over two or more +/// conditions; or `not` over one. In an operand, a property the document +/// leaves out counts as 0, or as the value of an `ifAbsent` operand naming +/// it. Arithmetic is exact `i128`: `divide` and `modulo` are Euclidean (the +/// remainder is never negative), and an overflow, a zero divisor, a +/// negative exponent or a value that is not an integer refuses the +/// document rather than wrapping. Conditions are checked in declared order +/// and no further than the outcome needs (`anyOf` stops at the first that +/// holds, `allOf` at the first that fails), a fault in one that is checked +/// refuses the document whatever the others say, and `not` never turns a +/// fault into a pass, so an earlier condition guards a later one. The +/// parser checks that every path an operand reads names an integer +/// property and every path `present` or `absent` tests names a property of +/// any type, neither transient nor inside a transient object; that every +/// comparison reads a property; that an `anyOf` or `allOf` holds none +/// directly of its own kind and a `not` no `not`; and that no condition or +/// operand nests deeper than `MAX_PROPERTY_CONSTRAINT_PARSE_DEPTH` (64), on +/// every parse. Under full validation it holds the limits +/// `SystemLimits::max_property_constraints` (16 rules) and +/// `max_property_constraint_nodes` (32 per rule, every comparison, +/// presence test and logical operator counting as one), and that no +/// `anyOf` or `allOf` lists the same condition twice. /// `DataContract::validate_document_properties` 0 (extended in place, inert /// before this version) calls `validate_property_constraints` /// (`validate_property_constraints` 0) after the schema validation, so diff --git a/packages/wasm-dpp2/src/consensus_error.rs b/packages/wasm-dpp2/src/consensus_error.rs index 16e16f3bcc0..dd0278a42e3 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -255,9 +255,9 @@ impl DocumentMaxBytesErrorCodeWasm { #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum DocumentPropertyConstraintErrorCodeWasm { /// The written document breaks a rule of its document type's - /// `propertyConstraints`: the rule (a comparison, or an `anyOf`, `allOf` or - /// `not` of them) does not hold, or evaluating it overflowed, divided by - /// zero, raised to a negative power or read a value that is not an integer. + /// `propertyConstraints`: the rule does not hold, or evaluating it + /// overflowed, divided by zero, raised to a negative power or read a value + /// that is not an integer. DocumentPropertyConstraintViolated = 10422, }