Repository: frictionlessdata/frictionless-py

Issue:
Feature request: number of records constraints
# Overview

Hi,
below you have one of the official documentation yaml file. There some constraints fields related.

A lot of time I know that I know that a resource is valid only if it is made up of at least 365 rows (some annual dataset including leap years). Or that the rows shouldn't be more than 10.

I think it should be a great validate and check feature to have.

Thank you

```
dialect:
  delimiter: ;
encoding: utf-8
format: csv
hashing: md5
layout:
  headerRows:
    - 2
name: country-2
path: country-2.csv
profile: tabular-data-resource
schema:
  fields:
    - name: id
      title: Identifier
      type: integer
    - name: neighbor_id
      title: Identifier of the neighbor
      type: integer
    - name: name
      title: Name of the country
      type: string
    - constraints:
        minimum: 0
      description: According to the year 2020's data
      name: population
      title: Population
      type: integer
  foreignKeys:
    - fields:
        - neighbor_id
      reference:
        fields:
          - id
        resource: ''
scheme: file
```

---

Please preserve this line to notify @roll (lead of this repository)


Discussion:
roll: Thanks @aivuk!

I'll review on Monday. 

A few comments:
- don't forget using `make test / make link / make format / etc` locally
- please rename to `table_dimenstion` check and add max fields and min fields params
roll: Hi, @aivuk, It already looks good in my opinion. Just in case I suggest adding a few edge case tests like:
- more than one limit provided
- no limits provided
- limits provided inside a descriptor (not as an arg)
aivuk: I added other tests, but what do you mean with:

> limits provided inside a descriptor (not as an arg)

Creating a schema? Do you have an example?


aivuk: Added the declarative tests @roll 

Patch:
diff --git a/data/table-limits.csv b/data/table-limits.csv
new file mode 100644
index 00000000..9ad35efb
--- /dev/null
+++ b/data/table-limits.csv
@@ -0,0 +1,4 @@
+field1, field2, field3, field4
+1, 2, 3, 4
+5, 6, 7, 8
+9, 10, 11, 12
diff --git a/frictionless/checks/__init__.py b/frictionless/checks/__init__.py
index a6a9978c..2759adaf 100644
--- a/frictionless/checks/__init__.py
+++ b/frictionless/checks/__init__.py
@@ -1,3 +1,8 @@
 from .baseline import baseline
 from .heuristic import duplicate_row, deviated_value, truncated_value
-from .regulation import forbidden_value, sequential_value, row_constraint
+from .regulation import (
+    forbidden_value,
+    sequential_value,
+    row_constraint,
+    table_dimensions,
+)
diff --git a/frictionless/checks/regulation.py b/frictionless/checks/regulation.py
index 20eaab6f..674d7ffc 100644
--- a/frictionless/checks/regulation.py
+++ b/frictionless/checks/regulation.py
@@ -59,6 +59,134 @@ class forbidden_value(Check):
     }
 
 
+class table_dimensions(Check):
+    """Check for minimum and maximum table dimensions
+
+    API      | Usage
+    -------- | --------
+    Public   | `from frictionless import checks`
+    Implicit | `validate(checks=[{"code": "table-dimensions", numRows, minRows, maxRows, numFields, minFields, maxFields}])`
+
+    Parameters:
+       descriptor (dict): check's descriptor
+
+    """
+
+    code = "table-dimensions"
+    Errors = [errors.TableDimensionsError]
+
+    def __init__(
+        self,
+        descriptor=None,
+        *,
+        num_rows=None,
+        num_fields=None,
+        min_rows=None,
+        max_rows=None,
+        min_fields=None,
+        max_fields=None
+    ):
+        self.setinitial("numRows", num_rows)
+        self.setinitial("numFields", num_fields)
+        self.setinitial("minRows", min_rows)
+        self.setinitial("maxRows", max_rows)
+        self.setinitial("minFields", min_fields)
+        self.setinitial("maxFields", max_fields)
+        super().__init__(descriptor)
+        self.__num_rows = self["numRows"] if "numRows" in self else -1
+        self.__num_fields = self["numFields"] if "numFields" in self else -1
+        self.__min_rows = self["minRows"] if "minRows" in self else -1
+        self.__max_rows = self["maxRows"] if "maxRows" in self else -1
+        self.__min_fields = self["minFields"] if "minFields" in self else -1
+        self.__max_fields = self["maxFields"] if "maxFields" in self else -1
+
+    # Validate
+
+    def validate_start(self):
+        number_fields = len(self.resource.schema.fields)
+
+        # Check if there is a different number of fields as required
+        if self.__num_fields > 0 and number_fields != self.__num_fields:
+            yield errors.TableDimensionsError(
+                note="Current number of fields is %s, the required number is %s"
+                % (number_fields, self.__num_fields),
+                limits={
+                    "requiredNumFields": self.__num_fields,
+                    "numberFields": number_fields,
+                },
+            )
+
+        # Check if there is less field than the minimum
+        if self.__min_fields > 0 and number_fields < self.__min_fields:
+            yield errors.TableDimensionsError(
+                note="Current number of fields is %s, the minimum is %s"
+                % (number_fields, self.__min_fields),
+                limits={"minFields": self.__min_fields, "numberFields": number_fields},
+            )
+
+        # Check if there is more field than the maximum
+        if self.__max_fields > 0 and number_fields > self.__max_fields:
+            yield errors.TableDimensionsError(
+                note="Current number of fields is %s, the maximum is %s"
+                % (number_fields, self.__max_fields),
+                limits={"maxFields": self.__max_fields, "numberFields": number_fields},
+            )
+
+    def validate_row(self, row):
+        self.__last_row = row
+        number_rows = self.__last_row.row_number
+        # Check if exceed the max number of rows
+        if self.__max_rows > 0 and self.__last_row.row_number > self.__max_rows:
+            yield errors.TableDimensionsError(
+                note="Current number of rows is %s, the maximum is %s"
+                % (number_rows, self.__max_rows),
+                limits={"maxRows": self.__max_rows, "numberRows": number_rows},
+            )
+
+    def validate_end(self):
+        number_rows = self.__last_row.row_number
+
+        # Check if doesn't have the exact number of rows
+        if self.__num_rows > 0 and number_rows != self.__num_rows:
+            yield errors.TableDimensionsError(
+                note="Current number of rows is %s, the required is %s"
+                % (number_rows, self.__num_rows),
+                limits={"requiredNumRows": self.__num_rows, "numberRows": number_rows},
+            )
+
+        # Check if has less rows than the required
+        if self.__min_rows > 0 and number_rows < self.__min_rows:
+            yield errors.TableDimensionsError(
+                note="Current number of rows is %s, the minimum is %s"
+                % (number_rows, self.__min_rows),
+                limits={"minRows": self.__min_rows, "numberRows": number_rows},
+            )
+
+    # Metadata
+
+    metadata_profile = {  # type: ignore
+        "type": "object",
+        "requred": {
+            "oneOf": [
+                "numRows",
+                "minRows",
+                "maxRows",
+                "numFields",
+                "minFields",
+                "maxFields",
+            ]
+        },
+        "properties": {
+            "numRows": {"type": "number"},
+            "minRows": {"type": "number"},
+            "maxRows": {"type": "number"},
+            "numFields": {"type": "number"},
+            "minFields": {"type": "number"},
+            "maxFields": {"type": "number"},
+        },
+    }
+
+
 class sequential_value(Check):
     """Check that a column having sequential values
 
diff --git a/frictionless/errors/table.py b/frictionless/errors/table.py
index 1cf287a2..c5eb51d6 100644
--- a/frictionless/errors/table.py
+++ b/frictionless/errors/table.py
@@ -23,6 +23,17 @@ class RowCountError(TableError):
     description = "This error can happen if the data is corrupted."
 
 
+class TableDimensionsError(TableError):
+    code = "table-dimensions-error"
+    name = "Table dimensions error"
+    template = "The data source does not have the required dimensions: {note}"
+    description = "This error can happen if the data is corrupted."
+
+    def __init__(self, note, limits):
+        self.setinitial("limits", limits)
+        super().__init__(note=note)
+
+
 class DeviatedValueError(TableError):
     code = "deviated-value"
     name = "Deviated Value"


Test patch:
diff --git a/tests/checks/test_regulation.py b/tests/checks/test_regulation.py
index aef58179..5a333ba9 100644
--- a/tests/checks/test_regulation.py
+++ b/tests/checks/test_regulation.py
@@ -164,3 +164,215 @@ def test_validate_row_constraint_list_in_formula_issue_817():
         ],
     )
     assert report.valid
+
+
+def test_validate_table_dimensions_num_rows():
+    report = validate(
+        "data/table-limits.csv",
+        checks=[checks.regulation.table_dimensions(num_rows=42)],
+    )
+    assert report.flatten(["limits", "code"]) == [
+        [{"requiredNumRows": 42, "numberRows": 3}, "table-dimensions-error"]
+    ]
+
+
+def test_validate_table_dimensions_num_rows_declarative():
+    report = validate(
+        "data/table-limits.csv",
+        checks=[{"code": "table-dimensions", "numRows": 42}],
+    )
+    assert report.flatten(["limits", "code"]) == [
+        [{"requiredNumRows": 42, "numberRows": 3}, "table-dimensions-error"]
+    ]
+
+
+def test_validate_table_dimensions_min_rows():
+    report = validate(
+        "data/table-limits.csv",
+        checks=[checks.regulation.table_dimensions(min_rows=42)],
+    )
+    assert report.flatten(["limits", "code"]) == [
+        [{"minRows": 42, "numberRows": 3}, "table-dimensions-error"]
+    ]
+
+
+def test_validate_table_dimensions_min_rows_declarative():
+    report = validate(
+        "data/table-limits.csv",
+        checks=[{"code": "table-dimensions", "minRows": 42}],
+    )
+    assert report.flatten(["limits", "code"]) == [
+        [{"minRows": 42, "numberRows": 3}, "table-dimensions-error"]
+    ]
+
+
+def test_validate_table_dimensions_max_rows():
+    report = validate(
+        "data/table-limits.csv",
+        checks=[checks.regulation.table_dimensions(max_rows=2)],
+    )
+    assert report.flatten(["limits", "code"]) == [
+        [{"maxRows": 2, "numberRows": 3}, "table-dimensions-error"]
+    ]
+
+
+def test_validate_table_dimensions_max_rows_declarative():
+    report = validate(
+        "data/table-limits.csv",
+        checks=[{"code": "table-dimensions", "maxRows": 2}],
+    )
+    assert report.flatten(["limits", "code"]) == [
+        [{"maxRows": 2, "numberRows": 3}, "table-dimensions-error"]
+    ]
+
+
+def test_validate_table_dimensions_num_fields():
+    report = validate(
+        "data/table-limits.csv",
+        checks=[checks.regulation.table_dimensions(num_fields=42)],
+    )
+    assert report.flatten(["limits", "code"]) == [
+        [{"requiredNumFields": 42, "numberFields": 4}, "table-dimensions-error"]
+    ]
+
+
+def test_validate_table_dimensions_num_fields_declarative():
+    report = validate(
+        "data/table-limits.csv", checks=[{"code": "table-dimensions", "numFields": 42}]
+    )
+
+    assert report.flatten(["limits", "code"]) == [
+        [{"requiredNumFields": 42, "numberFields": 4}, "table-dimensions-error"]
+    ]
+
+
+def test_validate_table_dimensions_min_fields():
+    report = validate(
+        "data/table-limits.csv",
+        checks=[checks.regulation.table_dimensions(min_fields=42)],
+    )
+    assert report.flatten(["limits", "code"]) == [
+        [{"minFields": 42, "numberFields": 4}, "table-dimensions-error"]
+    ]
+
+
+def test_validate_table_dimensions_min_fields_declarative():
+    report = validate(
+        "data/table-limits.csv",
+        checks=[{"code": "table-dimensions", "minFields": 42}],
+    )
+    assert report.flatten(["limits", "code"]) == [
+        [{"minFields": 42, "numberFields": 4}, "table-dimensions-error"]
+    ]
+
+
+def test_validate_table_dimensions_max_fields():
+    report = validate(
+        "data/table-limits.csv",
+        checks=[checks.regulation.table_dimensions(max_fields=2)],
+    )
+    assert report.flatten(["limits", "code"]) == [
+        [{"maxFields": 2, "numberFields": 4}, "table-dimensions-error"]
+    ]
+
+
+def test_validate_table_dimensions_max_fields_declarative():
+    report = validate(
+        "data/table-limits.csv",
+        checks=[{"code": "table-dimensions", "maxFields": 2}],
+    )
+    assert report.flatten(["limits", "code"]) == [
+        [{"maxFields": 2, "numberFields": 4}, "table-dimensions-error"]
+    ]
+
+
+def test_validate_table_dimensions_no_limits():
+    report = validate(
+        "data/table-limits.csv",
+        checks=[checks.regulation.table_dimensions()],
+    )
+    assert report.flatten(["limits", "code"]) == []
+
+
+def test_validate_table_dimensions_no_limits_declarative():
+    report = validate(
+        "data/table-limits.csv",
+        checks=[{"code": "table-dimensions"}],
+    )
+    assert report.flatten(["limits", "code"]) == []
+
+
+def test_validate_table_dimensions_num_fields_num_rows_wrong():
+    report = validate(
+        "data/table-limits.csv",
+        checks=[checks.regulation.table_dimensions(num_fields=3, num_rows=2)],
+    )
+    assert report.flatten(["limits", "code"]) == [
+        [{"requiredNumFields": 3, "numberFields": 4}, "table-dimensions-error"],
+        [{"requiredNumRows": 2, "numberRows": 3}, "table-dimensions-error"],
+    ]
+
+
+def test_validate_table_dimensions_num_fields_num_rows_wrong_declarative():
+    report = validate(
+        "data/table-limits.csv",
+        checks=[{"code": "table-dimensions", "numFields": 3, "numRows": 2}],
+    )
+    assert report.flatten(["limits", "code"]) == [
+        [{"requiredNumFields": 3, "numberFields": 4}, "table-dimensions-error"],
+        [{"requiredNumRows": 2, "numberRows": 3}, "table-dimensions-error"],
+    ]
+
+
+def test_validate_table_dimensions_num_fields_num_rows_correct():
+    report = validate(
+        "data/table-limits.csv",
+        checks=[checks.regulation.table_dimensions(num_fields=4, num_rows=3)],
+    )
+    assert report.flatten(["limits", "code"]) == []
+
+
+def test_validate_table_dimensions_num_fields_num_rows_correct_declarative():
+    report = validate(
+        "data/table-limits.csv",
+        checks=[{"code": "table-dimensions", "numFields": 4, "numRows": 3}],
+    )
+    assert report.flatten(["limits", "code"]) == []
+
+
+def test_validate_table_dimensions_min_fields_max_rows_wrong():
+    report = validate(
+        "data/table-limits.csv",
+        checks=[checks.regulation.table_dimensions(min_fields=5, max_rows=2)],
+    )
+    assert report.flatten(["limits", "code"]) == [
+        [{"minFields": 5, "numberFields": 4}, "table-dimensions-error"],
+        [{"maxRows": 2, "numberRows": 3}, "table-dimensions-error"],
+    ]
+
+
+def test_validate_table_dimensions_min_fields_max_rows_wrong_declarative():
+    report = validate(
+        "data/table-limits.csv",
+        checks=[{"code": "table-dimensions", "minFields": 5, "maxRows": 2}],
+    )
+    assert report.flatten(["limits", "code"]) == [
+        [{"minFields": 5, "numberFields": 4}, "table-dimensions-error"],
+        [{"maxRows": 2, "numberRows": 3}, "table-dimensions-error"],
+    ]
+
+
+def test_validate_table_dimensions_min_fields_max_rows_correct():
+    report = validate(
+        "data/table-limits.csv",
+        checks=[checks.regulation.table_dimensions(min_fields=4, max_rows=3)],
+    )
+    assert report.flatten(["limits", "code"]) == []
+
+
+def test_validate_table_dimensions_min_fields_max_rows_correct_declarative():
+    report = validate(
+        "data/table-limits.csv",
+        checks=[{"code": "table-dimensions", "minFields": 4, "maxRows": 3}],
+    )
+    assert report.flatten(["limits", "code"]) == []