Repository: meltano/sdk
Pull request #2241 (2024-02-14 01:02:38)

feat: JSON schema keywords such as `maxLength` are now supported in `StringType`, `IntegerType` and `NumberType` JSON schema helpers


<!-- readthedocs-preview meltano-sdk start -->
----
📚 Documentation preview 📚: https://meltano-sdk--2241.org.readthedocs.build/en/2241/

<!-- readthedocs-preview meltano-sdk end -->

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/meltano/sdk/pull/2241?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) Report
Attention: `2 lines` in your changes are missing coverage. Please review.
> Comparison is base [(`2de41df`)](https://app.codecov.io/gh/meltano/sdk/commit/2de41df7715ea7f81a646078e6ca64258d340dbf?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) 88.47% compared to head [(`69ba4d7`)](https://app.codecov.io/gh/meltano/sdk/pull/2241?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) 88.51%.

| [Files](https://app.codecov.io/gh/meltano/sdk/pull/2241?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) | Patch % | Lines |
|---|---|---|
| [singer\_sdk/typing.py](https://app.codecov.io/gh/meltano/sdk/pull/2241?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano#diff-c2luZ2VyX3Nkay90eXBpbmcucHk=) | 95.12% | [1 Missing and 1 partial :warning: ](https://app.codecov.io/gh/meltano/sdk/pull/2241?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #2241      +/-   ##
==========================================
+ Coverage   88.47%   88.51%   +0.03%     
==========================================
  Files          54       54              
  Lines        4721     4752      +31     
  Branches      919      926       +7     
==========================================
+ Hits         4177     4206      +29     
- Misses        383      384       +1     
- Partials      161      162       +1     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/meltano/sdk/pull/2241?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano).

codspeed-hq[bot]: ## [CodSpeed Performance Report](https://codspeed.io/meltano/sdk/branches/edgarrmondragon/feat/jsonschema-string-numeric-keywords)

### Merging #2241 will **not alter performance**

<sub>Comparing <code>edgarrmondragon/feat/jsonschema-string-numeric-keywords</code> (69ba4d7) with <code>main</code> (2de41df)</sub>



### Summary

`✅ 6` untouched benchmarks






Patch:
diff --git a/docs/_templates/class.rst b/docs/_templates/class.rst
index 4ebc9be0..ffd79f48 100644
--- a/docs/_templates/class.rst
+++ b/docs/_templates/class.rst
@@ -6,3 +6,6 @@
 .. autoclass:: {{ name }}
     :members:
     :special-members: __init__, __call__
+    {%- if name in ('IntegerType', 'NumberType') %}
+    :inherited-members: JSONTypeHelper
+    {%- endif %}
diff --git a/docs/classes/typing/singer_sdk.typing.IntegerType.rst b/docs/classes/typing/singer_sdk.typing.IntegerType.rst
index 23475e03..b2097aca 100644
--- a/docs/classes/typing/singer_sdk.typing.IntegerType.rst
+++ b/docs/classes/typing/singer_sdk.typing.IntegerType.rst
@@ -5,4 +5,5 @@
 
 .. autoclass:: IntegerType
     :members:
-    :special-members: __init__, __call__
\ No newline at end of file
+    :special-members: __init__, __call__
+    :inherited-members: JSONTypeHelper
\ No newline at end of file
diff --git a/docs/classes/typing/singer_sdk.typing.NumberType.rst b/docs/classes/typing/singer_sdk.typing.NumberType.rst
index e71147be..09c29edf 100644
--- a/docs/classes/typing/singer_sdk.typing.NumberType.rst
+++ b/docs/classes/typing/singer_sdk.typing.NumberType.rst
@@ -5,4 +5,5 @@
 
 .. autoclass:: NumberType
     :members:
-    :special-members: __init__, __call__
\ No newline at end of file
+    :special-members: __init__, __call__
+    :inherited-members: JSONTypeHelper
\ No newline at end of file
diff --git a/docs/conf.py b/docs/conf.py
index b60cd206..ada3ecdf 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -40,6 +40,7 @@ extensions = [
     "sphinx.ext.napoleon",
     "sphinx.ext.autosectionlabel",
     "sphinx.ext.autosummary",
+    "sphinx.ext.extlinks",
     "sphinx.ext.intersphinx",
     "sphinx.ext.linkcode",
     "sphinx_copybutton",
@@ -138,6 +139,16 @@ redirects = {
     "porting.html": "guides/porting.html",
 }
 
+# -- Options for extlinks -----------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/extensions/extlinks.html
+
+extlinks = {
+    "jsonschema": (
+        "https://json-schema.org/understanding-json-schema/reference/%s",
+        "%s",
+    ),
+}
+
 # -- Options for intersphinx -----------------------------------------------------------
 # https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html#configuration
 intersphinx_mapping = {
diff --git a/singer_sdk/typing.py b/singer_sdk/typing.py
index 7a412fe8..8e68a13a 100644
--- a/singer_sdk/typing.py
+++ b/singer_sdk/typing.py
@@ -266,22 +266,46 @@ class StringType(JSONTypeHelper[str]):
         {'type': ['string']}
         >>> StringType(allowed_values=["a", "b"]).type_dict
         {'type': ['string'], 'enum': ['a', 'b']}
+        >>> StringType(max_length=10).type_dict
+        {'type': ['string'], 'maxLength': 10}
     """
 
     string_format: str | None = None
     """String format.
 
-    See the `formats built into the JSON Schema specification`_.
+    See the :jsonschema:`JSON Schema reference <string#built-in-formats>` for a list of
+    all the built-in formats.
 
     Returns:
         A string describing the format.
-
-    .. _`formats built into the JSON Schema specification`:
-        https://json-schema.org/understanding-json-schema/reference/string.html#built-in-formats
     """
 
+    def __init__(
+        self,
+        *,
+        min_length: int | None = None,
+        max_length: int | None = None,
+        pattern: str | None = None,
+        **kwargs: t.Any,
+    ) -> None:
+        """Initialize StringType.
+
+        Args:
+            min_length: Minimum length of the string. See the
+                :jsonschema:`JSON Schema reference <string#length>` for details.
+            max_length: Maximum length of the string. See the
+                :jsonschema:`JSON Schema reference <string#length>` for details.
+            pattern: A regular expression pattern that the string must match. See the
+                :jsonschema:`JSON Schema reference <string#regexp>` for details.
+            **kwargs: Additional keyword arguments to pass to the parent class.
+        """
+        super().__init__(**kwargs)
+        self.min_length = min_length
+        self.max_length = max_length
+        self.pattern = pattern
+
     @property
-    def _format(self) -> dict:
+    def _format(self) -> dict[str, t.Any]:
         return {"format": self.string_format} if self.string_format else {}
 
     @DefaultInstanceProperty
@@ -291,12 +315,23 @@ class StringType(JSONTypeHelper[str]):
         Returns:
             A dictionary describing the type.
         """
-        return {
+        result = {
             "type": ["string"],
             **self._format,
             **self.extras,
         }
 
+        if self.max_length is not None:
+            result["maxLength"] = self.max_length
+
+        if self.min_length is not None:
+            result["minLength"] = self.min_length
+
+        if self.pattern is not None:
+            result["pattern"] = self.pattern
+
+        return result
+
 
 class DateTimeType(StringType):
     """DateTime type.
@@ -423,7 +458,71 @@ class BooleanType(JSONTypeHelper[bool]):
         return {"type": ["boolean"], **self.extras}
 
 
-class IntegerType(JSONTypeHelper):
+class _NumericType(JSONTypeHelper[T]):
+    """Abstract numeric type for integers and numbers."""
+
+    __type_name__: str
+
+    def __init__(
+        self,
+        *,
+        minimum: int | None = None,
+        maximum: int | None = None,
+        exclusive_minimum: int | None = None,
+        exclusive_maximum: int | None = None,
+        multiple_of: int | None = None,
+        **kwargs: t.Any,
+    ) -> None:
+        """Initialize IntegerType.
+
+        Args:
+            minimum: Minimum numeric value. See the
+                :jsonschema:`JSON Schema reference <numeric#range>` for details.
+            maximum: Maximum numeric value.
+                :jsonschema:`JSON Schema reference <numeric#range>` for details.
+            exclusive_minimum: Exclusive minimum numeric value.
+                :jsonschema:`JSON Schema reference <numeric#range>` for details.
+            exclusive_maximum: Exclusive maximum numeric value. See the
+                :jsonschema:`JSON Schema reference <numeric#range>` for details.
+            multiple_of: A number that the value must be a multiple of. See the
+                :jsonschema:`JSON Schema reference <numeric#multiples>` for details.
+            **kwargs: Additional keyword arguments to pass to the parent class.
+        """
+        super().__init__(**kwargs)
+        self.minimum = minimum
+        self.maximum = maximum
+        self.exclusive_minimum = exclusive_minimum
+        self.exclusive_maximum = exclusive_maximum
+        self.multiple_of = multiple_of
+
+    @DefaultInstanceProperty
+    def type_dict(self) -> dict:
+        """Get type dictionary.
+
+        Returns:
+            A dictionary describing the type.
+        """
+        result = {"type": [self.__type_name__], **self.extras}
+
+        if self.minimum is not None:
+            result["minimum"] = self.minimum
+
+        if self.maximum is not None:
+            result["maximum"] = self.maximum
+
+        if self.exclusive_minimum is not None:
+            result["exclusiveMinimum"] = self.exclusive_minimum
+
+        if self.exclusive_maximum is not None:
+            result["exclusiveMaximum"] = self.exclusive_maximum
+
+        if self.multiple_of is not None:
+            result["multipleOf"] = self.multiple_of
+
+        return result
+
+
+class IntegerType(_NumericType[int]):
     """Integer type.
 
     Examples:
@@ -433,19 +532,18 @@ class IntegerType(JSONTypeHelper):
         {'type': ['integer']}
         >>> IntegerType(allowed_values=[1, 2]).type_dict
         {'type': ['integer'], 'enum': [1, 2]}
+        >>> IntegerType(minimum=0, maximum=10).type_dict
+        {'type': ['integer'], 'minimum': 0, 'maximum': 10}
+        >>> IntegerType(exclusive_minimum=0, exclusive_maximum=10).type_dict
+        {'type': ['integer'], 'exclusiveMinimum': 0, 'exclusiveMaximum': 10}
+        >>> IntegerType(multiple_of=2).type_dict
+        {'type': ['integer'], 'multipleOf': 2}
     """
 
-    @DefaultInstanceProperty
-    def type_dict(self) -> dict:
-        """Get type dictionary.
-
-        Returns:
-            A dictionary describing the type.
-        """
-        return {"type": ["integer"], **self.extras}
+    __type_name__ = "integer"
 
 
-class NumberType(JSONTypeHelper[float]):
+class NumberType(_NumericType[float]):
     """Number type.
 
     Examples:
@@ -455,16 +553,15 @@ class NumberType(JSONTypeHelper[float]):
         {'type': ['number']}
         >>> NumberType(allowed_values=[1.0, 2.0]).type_dict
         {'type': ['number'], 'enum': [1.0, 2.0]}
+        >>> NumberType(minimum=0, maximum=10).type_dict
+        {'type': ['number'], 'minimum': 0, 'maximum': 10}
+        >>> NumberType(exclusive_minimum=0, exclusive_maximum=10).type_dict
+        {'type': ['number'], 'exclusiveMinimum': 0, 'exclusiveMaximum': 10}
+        >>> NumberType(multiple_of=2).type_dict
+        {'type': ['number'], 'multipleOf': 2}
     """
 
-    @DefaultInstanceProperty
-    def type_dict(self) -> dict:
-        """Get type dictionary.
-
-        Returns:
-            A dictionary describing the type.
-        """
-        return {"type": ["number"], **self.extras}
+    __type_name__ = "number"
 
 
 W = t.TypeVar("W", bound=JSONTypeHelper)


Test patch:
diff --git a/tests/core/test_jsonschema_helpers.py b/tests/core/test_jsonschema_helpers.py
index 8438a616..8fb5e90a 100644
--- a/tests/core/test_jsonschema_helpers.py
+++ b/tests/core/test_jsonschema_helpers.py
@@ -502,6 +502,50 @@ def test_inbuilt_type(json_type: JSONTypeHelper, expected_json_schema: dict):
             },
             {is_array_type, is_string_array_type},
         ),
+        (
+            Property(
+                "my_prop12",
+                StringType(min_length=5, max_length=10, pattern="^a.*b$"),
+            ),
+            {
+                "my_prop12": {
+                    "type": ["string", "null"],
+                    "minLength": 5,
+                    "maxLength": 10,
+                    "pattern": "^a.*b$",
+                },
+            },
+            {is_string_type},
+        ),
+        (
+            Property(
+                "my_prop13",
+                IntegerType(minimum=5, maximum=10),
+            ),
+            {
+                "my_prop13": {
+                    "type": ["integer", "null"],
+                    "minimum": 5,
+                    "maximum": 10,
+                },
+            },
+            {is_integer_type},
+        ),
+        (
+            Property(
+                "my_prop14",
+                IntegerType(exclusive_minimum=5, exclusive_maximum=10, multiple_of=2),
+            ),
+            {
+                "my_prop14": {
+                    "type": ["integer", "null"],
+                    "exclusiveMinimum": 5,
+                    "exclusiveMaximum": 10,
+                    "multipleOf": 2,
+                },
+            },
+            {is_integer_type},
+        ),
     ],
 )
 def test_property_creation(