Repository: jdkandersson/OpenAlchemy

Issue:
Support byte
As a user I want to define a byte format so that I can store and retrieve base64 encoded values from the database.

Support byte as indicated by the appropriate format directive of a string.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e44f4266..cc8553aa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
 
 ## Version _next_
 
+- Add support for _password_
+- Add support for _binary_
 - Add support for _byte_
 - Add support for _date_
 - Move SQLAlchemy relationship construction behind facade
diff --git a/README.md b/README.md
index 4684c692..87a5c8c1 100644
--- a/README.md
+++ b/README.md
@@ -100,7 +100,9 @@ An example API has been defined using connexion and Flask here:
 - `number` (float only),
 - `boolean`,
 - `string`,
+- `password`,
 - `byte`,
+- `binary`,
 - `date`,
 - `date-time`,
 - `$ref` references for columns and models,
diff --git a/docs/source/technical_details/type_mapping.rst b/docs/source/technical_details/type_mapping.rst
index 077313ac..bf7406ba 100644
--- a/docs/source/technical_details/type_mapping.rst
+++ b/docs/source/technical_details/type_mapping.rst
@@ -19,8 +19,12 @@ following mappings:
 +--------------+----------------+-----------------+
 | string       | *undefined*    | String          |
 +--------------+----------------+-----------------+
+|              | password       | String          |
++--------------+----------------+-----------------+
 |              | byte           | String          |
 +--------------+----------------+-----------------+
+|              | binary         | LargeBinary     |
++--------------+----------------+-----------------+
 |              | date           | Date            |
 +--------------+----------------+-----------------+
 |              | date-time      | DateTime        |
@@ -35,6 +39,26 @@ String
 translated to the *length* argument for the *SQLAlchemy* *String*, which is set
 to *None* if *maxLength* is *undefined*.
 
+Password
+--------
+
+The same *maxLength* information as for *String* also applies.
+
+.. note:: The *password* format under the hood is the same as *String*. No
+    special protection (such as encryption) is added.
+
+Byte
+------
+
+This format is for *base64* encoded binary data. The same *maxLength*
+information as for *String* also applies.
+
+Binary
+------
+
+The same *maxLength* information as for *String* also applies. The codec is
+assumed to be *utf-8*.
+
 DateTime
 --------
 
diff --git a/open_alchemy/column_factory/column.py b/open_alchemy/column_factory/column.py
index 7a2874aa..395e470a 100644
--- a/open_alchemy/column_factory/column.py
+++ b/open_alchemy/column_factory/column.py
@@ -272,10 +272,14 @@ def _handle_string(*, artifacts: types.ColumnArtifacts) -> sqlalchemy.String:
         raise exceptions.MalformedSchemaError(
             "The string type does not support autoincrement."
         )
-    if artifacts.format in {None, "byte"}:
+    if artifacts.format in {None, "byte", "password"}:
         if artifacts.max_length is None:
             return sqlalchemy.String
         return sqlalchemy.String(length=artifacts.max_length)
+    if artifacts.format == "binary":
+        if artifacts.max_length is None:
+            return sqlalchemy.LargeBinary
+        return sqlalchemy.LargeBinary(length=artifacts.max_length)
     if artifacts.format == "date":
         return sqlalchemy.Date
     if artifacts.format == "date-time":
diff --git a/open_alchemy/utility_base.py b/open_alchemy/utility_base.py
index dcdff3cc..042d8c20 100644
--- a/open_alchemy/utility_base.py
+++ b/open_alchemy/utility_base.py
@@ -99,12 +99,23 @@ class UtilityBase:
         return ref_model
 
     @staticmethod
-    def _from_dict(
+    def _model_from_dict(
         kwargs: typing.Dict[str, typing.Any], *, model: typing.Type[TUtilityBase]
     ) -> TUtilityBase:
         """Construct model from dictionary."""
         return model.from_dict(**kwargs)
 
+    @staticmethod
+    def _simple_type_from_dict(format_: str, value: typing.Any) -> typing.Any:
+        """Construct dictionary key for simple types."""
+        if format_ == "date":
+            return datetime.date.fromisoformat(value)
+        if format_ == "date-time":
+            return datetime.datetime.fromisoformat(value)
+        if format_ == "binary":
+            return value.encode()
+        return value
+
     @classmethod
     def from_dict(cls: typing.Type[TUtilityBase], **kwargs: typing.Any) -> TUtilityBase:
         """
@@ -168,7 +179,7 @@ class UtilityBase:
             ref_model: typing.Type[TUtilityBase]
             if type_ == "object":
                 ref_model = cls._get_model(spec=spec, name=name, schema=schema)
-                ref_model_instance = cls._from_dict(value, model=ref_model)
+                ref_model_instance = cls._model_from_dict(value, model=ref_model)
                 model_dict[name] = ref_model_instance
                 continue
 
@@ -183,19 +194,15 @@ class UtilityBase:
                         f"The model schema is {json.dumps(schema)}."
                     )
                 ref_model = cls._get_model(spec=item_spec, name=name, schema=schema)
-                model_from_dict = functools.partial(cls._from_dict, model=ref_model)
+                model_from_dict = functools.partial(
+                    cls._model_from_dict, model=ref_model
+                )
                 ref_model_instances = map(model_from_dict, value)
                 model_dict[name] = list(ref_model_instances)
                 continue
 
             # Handle other types
-            if format_ == "date":
-                model_dict[name] = datetime.date.fromisoformat(value)
-                continue
-            if format_ == "date-time":
-                model_dict[name] = datetime.datetime.fromisoformat(value)
-                continue
-            model_dict[name] = value
+            model_dict[name] = cls._simple_type_from_dict(format_=format_, value=value)
 
         return cls(**model_dict)
 
@@ -265,6 +272,8 @@ class UtilityBase:
             return value.isoformat()
         if format_ == "date-time":
             return value.isoformat()
+        if format_ == "binary":
+            return value.decode()
         return value
 
     @classmethod


Test patch:
diff --git a/tests/open_alchemy/column_factory/test_column.py b/tests/open_alchemy/column_factory/test_column.py
index d2a11fe6..3e78874a 100644
--- a/tests/open_alchemy/column_factory/test_column.py
+++ b/tests/open_alchemy/column_factory/test_column.py
@@ -522,8 +522,10 @@ def test_handle_string_invalid_format():
         ("date", sqlalchemy.Date),
         ("date-time", sqlalchemy.DateTime),
         ("byte", sqlalchemy.String),
+        ("password", sqlalchemy.String),
+        ("binary", sqlalchemy.LargeBinary),
     ],
-    ids=["None", "date", "date-time", "byte"],
+    ids=["None", "date", "date-time", "byte", "password", "binary"],
 )
 @pytest.mark.column
 def test_handle_string(format_, expected_type):
@@ -539,19 +541,24 @@ def test_handle_string(format_, expected_type):
     assert string == expected_type
 
 
+@pytest.mark.parametrize(
+    "format_, expected_type",
+    [(None, sqlalchemy.String), ("binary", sqlalchemy.LargeBinary)],
+    ids=["string", "binary"],
+)
 @pytest.mark.column
-def test_handle_string_max_length():
+def test_handle_string_max_length(format_, expected_type):
     """
-    GIVEN artifacts with max_length
+    GIVEN artifacts with max_length and given format
     WHEN _handle_string is called with the artifacts
-    THEN a string with a maximum length is returned.
+    THEN a given expected type column with a maximum length is returned.
     """
     length = 1
-    artifacts = types.ColumnArtifacts("string", max_length=length)
+    artifacts = types.ColumnArtifacts("string", max_length=length, format=format_)
 
     string = column._handle_string(artifacts=artifacts)
 
-    assert isinstance(string, sqlalchemy.String)
+    assert isinstance(string, expected_type)
     assert string.length == length
 
 
diff --git a/tests/open_alchemy/test_integration/test_database.py b/tests/open_alchemy/test_integration/test_database.py
index 5fe2482c..3e7f57f4 100644
--- a/tests/open_alchemy/test_integration/test_database.py
+++ b/tests/open_alchemy/test_integration/test_database.py
@@ -17,6 +17,9 @@ import open_alchemy
         ("integer", "int64", 1),
         ("number", None, 1.0),
         ("string", None, "some string"),
+        ("string", "password", "some password"),
+        ("string", "byte", "some string"),
+        ("string", "binary", b"some bytes"),
         ("string", "date", datetime.date(year=2000, month=1, day=1)),
         (
             "string",
@@ -25,7 +28,18 @@ import open_alchemy
         ),
         ("boolean", None, True),
     ],
-    ids=["integer", "int64", "number", "string", "date", "date-time", "boolean"],
+    ids=[
+        "integer",
+        "int64",
+        "number",
+        "string",
+        "password",
+        "byte",
+        "binary",
+        "date",
+        "date-time",
+        "boolean",
+    ],
 )
 @pytest.mark.integration
 def test_database_types(
diff --git a/tests/open_alchemy/test_integration/test_to_from_dict.py b/tests/open_alchemy/test_integration/test_to_from_dict.py
index 168e6b19..f1dc7ea3 100644
--- a/tests/open_alchemy/test_integration/test_to_from_dict.py
+++ b/tests/open_alchemy/test_integration/test_to_from_dict.py
@@ -10,13 +10,17 @@ import open_alchemy
     "column_schema, value",
     [
         ({"type": "integer", "x-primary-key": True}, 1),
+        (
+            {"type": "string", "format": "binary", "x-primary-key": True},
+            "some binary files",
+        ),
         ({"type": "string", "format": "date", "x-primary-key": True}, "2000-01-01"),
         (
             {"type": "string", "format": "date-time", "x-primary-key": True},
             "2000-01-01T01:01:01",
         ),
     ],
-    ids=["integer", "date", "date-time"],
+    ids=["integer", "binary", "date", "date-time"],
 )
 @pytest.mark.integration
 def test_basic_types(engine, sessionmaker, column_schema, value):
diff --git a/tests/open_alchemy/utility_base/test_from_dict.py b/tests/open_alchemy/utility_base/test_from_dict.py
index 049dba3f..9de3549c 100644
--- a/tests/open_alchemy/utility_base/test_from_dict.py
+++ b/tests/open_alchemy/utility_base/test_from_dict.py
@@ -150,6 +150,7 @@ def test_from_dict(schema, dictionary, __init__):
 @pytest.mark.parametrize(
     "format_, value, expected_value",
     [
+        ("binary", "some binary file", b"some binary file"),
         ("date", "2000-01-01", datetime.date(year=2000, month=1, day=1)),
         (
             "date-time",
@@ -157,7 +158,7 @@ def test_from_dict(schema, dictionary, __init__):
             datetime.datetime(year=2000, month=1, day=1, hour=1, minute=1, second=1),
         ),
     ],
-    ids=["date", "date-time"],
+    ids=["binary", "date", "date-time"],
 )
 @pytest.mark.utility_base
 def test_from_dict_string_format(format_, value, expected_value, __init__):
diff --git a/tests/open_alchemy/utility_base/test_to_dict.py b/tests/open_alchemy/utility_base/test_to_dict.py
index a441bb53..89c39c82 100644
--- a/tests/open_alchemy/utility_base/test_to_dict.py
+++ b/tests/open_alchemy/utility_base/test_to_dict.py
@@ -439,6 +439,7 @@ class TestToDictProperty:
     @pytest.mark.parametrize(
         "format_, value, expected_value",
         [
+            ("binary", b"some binary file", "some binary file"),
             ("date", datetime.date(year=2000, month=1, day=1), "2000-01-01"),
             (
                 "date-time",
@@ -448,7 +449,7 @@ class TestToDictProperty:
                 "2000-01-01T01:01:01",
             ),
         ],
-        ids=["date", "date-time"],
+        ids=["binary", "date", "date-time"],
     )
     @pytest.mark.utility_base
     def test_string_format(format_, value, expected_value):