From 3c33bb9a7bb14e6de6753c90903f60da5c379ca8 Mon Sep 17 00:00:00 2001 From: Leo Schick Date: Fri, 15 Dec 2023 18:13:51 +0100 Subject: [PATCH] WIP tests + mara_catalog.export --- mara_catalog/export.py | 75 ++++++++++++++++++ tests/__init__.py | 0 tests/databricks/__init__.py | 0 tests/docker-compose.yml | 20 +++++ tests/example_datalake/README.md | 1 + .../example_datalake/mt_cars/mt cars.parquet | Bin 0 -> 2932 bytes .../example_datalake/weather/Weather.parquet | Bin 0 -> 21006 bytes tests/local_config.py | 11 +++ tests/local_config.py.example | 11 +++ tests/mssql/__init__.py | 0 tests/postgres/__init__.py | 0 11 files changed, 118 insertions(+) create mode 100644 mara_catalog/export.py create mode 100644 tests/__init__.py create mode 100644 tests/databricks/__init__.py create mode 100644 tests/docker-compose.yml create mode 100644 tests/example_datalake/README.md create mode 100644 tests/example_datalake/mt_cars/mt cars.parquet create mode 100644 tests/example_datalake/weather/Weather.parquet create mode 100644 tests/local_config.py create mode 100644 tests/local_config.py.example create mode 100644 tests/mssql/__init__.py create mode 100644 tests/postgres/__init__.py diff --git a/mara_catalog/export.py b/mara_catalog/export.py new file mode 100644 index 0000000..571561d --- /dev/null +++ b/mara_catalog/export.py @@ -0,0 +1,75 @@ +"""Functions to export tables from a database to a storage""" + +from typing import Iterator, Union, List + +from mara_pipelines.pipelines import Command +from mara_db import formats +from .catalog import DbCatalog +from .schema import WriteSchema + +## TBD +from app.pipelines.transfer_to.write_file import WriteFile + + +def export_catalog_mara_commands(catalog: DbCatalog, storage_alias: str, base_path: str, + format: formats.Format, write_schema_file: bool = False, include: List[str] = None, + db_alias: str = None) -> Iterator[Union[Command, List[Command]]]: + """ + Returns pipeline tasks which exports a catalog to a storage. + + Args: + catalog: The catalog to be exported + storage_alias: the storage where the tables shall be exported to + base_path: the base path + format: the format as it should be exported + write_schema_file: if a sqlalchemy schema file shall be added into the table directory. + list: if you want to include only a predefined list of tables, pass over a list of table names here. + This is applied accross schemas since schema selection is not yet supported. (TODO Might be changed in the future.) + """ + + for table in catalog.tables: + table_name = table['name'] + schema_name = table.get('schema', catalog.default_schema) + if include: + if not table_name in include: + # skip tables defined in include + continue + table_path = f'{base_path}/{schema_name}/{table_name}' if catalog.has_schemas else f'{base_path}/{table_name}' + #yield Task(id=table_to_id(schema_name, table_name), + # description=f"Export table {schema_name}.{table_name} to storage {storage_alias}", + # commands= + yield [ + # TBD: when format is parquet, delete the folder content first + WriteFile(sql_statement=f'SELECT * FROM "{schema_name}"."{table_name}"', + db_alias=db_alias or catalog.db_alias, + storage_alias=storage_alias, + dest_file_name=clean_hadoop_path(f'{table_path}/part.0.parquet'), # TODO generic format ending would be nice here + compression=('snappy' if isinstance(format, formats.ParquetFormat) else None), + format=format) + ] + ([ + WriteSchema(table_name, + schema=schema_name, + db_alias=db_alias or catalog.db_alias, + storage_alias=storage_alias, + file_name=f'{clean_hadoop_path(table_path)}/_sqlalchemy_metadata.py') + ] if write_schema_file else []) + #, + # max_retries=2) + + +def table_to_id(schema_name, table_name) -> str: + return f'{schema_name}_{table_name}'.lower() + + +def clean_hadoop_path(path) -> str: + """ + Hadoop hides paths starting with '_' and '.'. With this function paths for tables are renamed so that they can be read via Hadoop. + """ + parts = [] + for part in str(path).split('/'): + if part.startswith('_') or part.startswith('.'): + parts.append('x' + part) + else: + parts.append(part) + + return '/'.join(parts) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/databricks/__init__.py b/tests/databricks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml new file mode 100644 index 0000000..1dc49cb --- /dev/null +++ b/tests/docker-compose.yml @@ -0,0 +1,20 @@ +version: '3.1' + +services: + postgres: + image: postgres:15 + environment: + POSTGRES_DB: mara + POSTGRES_USER: mara + POSTGRES_PASSWORD: mara + POSTGRES_HOST_AUTH_METHOD: md5 + ports: + - "5432" + + mssql: + image: mcr.microsoft.com/mssql/server:2022-latest + environment: + - ACCEPT_EULA=Y + - SA_PASSWORD=YourStrong@Passw0rd + ports: + - "1433" diff --git a/tests/example_datalake/README.md b/tests/example_datalake/README.md new file mode 100644 index 0000000..fea958a --- /dev/null +++ b/tests/example_datalake/README.md @@ -0,0 +1 @@ +The sample data is taken from https://www.tablab.app/datasets/sample/parquet diff --git a/tests/example_datalake/mt_cars/mt cars.parquet b/tests/example_datalake/mt_cars/mt cars.parquet new file mode 100644 index 0000000000000000000000000000000000000000..13085cda454c70e716c9a1a7076ba4ac93b86bc1 GIT binary patch literal 2932 zcma)8U1%KF6~1?LXLU!KS6Yp<+0MNTC-*r8FBjCM!b zA)}qu>`IaC1XO}eFb1noOdRsil04+WI3X^@kH!Jh2f3tAhEjq9#RQx}z)eU)TYB!y zZY5y{I&if2oH_U0?|kRnGnzg$o7Itij_UVf13H6cOphV-CYcftLJUjr_n3XUVw9i@cxAD zxpuADg_*h9XgYzJKadeU(+SFEu8^6a*b^0!&m?73+}BTwnG$AVzgif{96yqaBQn*k z9m0KIm8MF2Ay~<-xSnU1%}LL3ny>kOr3b82ZmsNlHM8W`nr_XhHEmDh&eh;l!-Er_ zU9LNUYkT-y-=u3d&1|lSU$Tz+HAt@HuDNCWUEz8El-~r^4}3V_iJ$D-OKY}@?-%{b zl4F*tw&yvuB`3g>)}do1^XK-O}Phasu0&!utm%oglCS z*PL)`J}y;{*$sHpbj`zlVDl=R@VzzP%ocJ-pmZ0AepA>{#dCrYRS}7pj4-iNMI-F? z7RK}dTNE)JVR?+rYdQALWq6VbC6@;j6BYFgev;<%c|}E8&@+;XrZg1|Nh;Y&=~P@I zi-QV74Bn4{?E#bCCL1FXC1@Djhont%`Nxu_t&^#rDHi%E$Qa15WRYum#Y_#c#qb&7 z?RG)X*LOn3sR%MOA;JU^g(A9-;$BR!3DWl*K{z!;&{ztM^`QaoMnMQj#K51vmw^f} zpi}fBKsM`uc4H0xn4U!CjWxE2v3g1l8k=)3g1!PmF+x1zFW`3>ek1z+@<{sf&_H{51LgOG4P?gI`}Koeciy9l zk&;;N)n`6G-5UdkiK`Mjdw=HoV`mjMck$-e*4&=MK-~|>MGC)Y1)r4boecU>rtHpp z*oDor;Cz2Z<*8Cq z12Ai{%+B=U^Qc?-Ese8J@Q3tfoJoJA{~3Q={8b9G>aYL)`uNDV+j}TwB&UoO0!sKYn($2J6jh<> zVm8Vlt7NPk00u(7%d%NKllpU(eaVaZY8IM(JulS})OX-so_zupz9iScGe5G2bO}d1lJu8QG{iU%wwdP z8Xdz38yIVJ0Qxn2pm)Q@74*?vaavP^zt+fSu$y7}9N#@pTK>DwZTa&tq7mAGP>vwY zfD=umshWwY*2|$7RD{b#klda(g8t4UZ!Le13BO_@!3iwG0Fxmf5FT77Sk(y5U<=d8 z3d4<#^={8IURzyA{&fTyN@cbDYGvVN{@qx%bpz>gzhH>Ve#PAQVgO!!rBTXM1g>qFwjRr;$58nmo#b+m`KPVyjBokhqenr z$YLA9KZNw#-2eeR5ZZVHAcUW?TQebqhpk}YxkM142vHm%ab8GYhywyXd=0`!HAk?m zwf03EkP1|+jbNiAeMtob(eUa41nud|NqE@0D-smE9p?`arw8J+XP80TLx{(D*t#oD z*e~r!S31&L35XOj0VAcw4jK$OdDz;D)Z%2V5ypY4Y~u8{N#KNET7wrQzX^O1a1YF>W!{}2uVH*D5{xANHJ=6dI literal 0 HcmV?d00001 diff --git a/tests/example_datalake/weather/Weather.parquet b/tests/example_datalake/weather/Weather.parquet new file mode 100644 index 0000000000000000000000000000000000000000..a31a6f500842047c88b8f8686622b84f5c348aed GIT binary patch literal 21006 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 a*&W1`Zia@z^GOHJ-z{DPSJM1XkpB;D