From 1e116f4c3ec6e0f3bc9daa588e73fd679b761a38 Mon Sep 17 00:00:00 2001 From: Shraddheya Shrivastava Date: Fri, 31 Mar 2023 23:11:52 +0530 Subject: [PATCH 01/14] ENH: add table join to formhandler. --- gramex/data.py | 43 ++++++++++++++- pytest/test_formhandler.py | 107 +++++++++++++++++++++++++++++++++++++ tests/sales_join.xlsx | Bin 0 -> 13088 bytes 3 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 pytest/test_formhandler.py create mode 100644 tests/sales_join.xlsx diff --git a/gramex/data.py b/gramex/data.py index 29d096a8..9edf5971 100644 --- a/gramex/data.py +++ b/gramex/data.py @@ -59,6 +59,7 @@ def filter( args: dict = {}, meta: dict = {}, engine: str = None, + join: str = None, table: str = None, ext: str = None, id: List[str] = None, @@ -313,7 +314,7 @@ def filter( data = gramex.cache.query(table, engine, [table]) return _filter_frame(transform(data), meta, controls, args, argstype) else: - return _filter_db(engine, table, meta, controls, args, argstype) + return _filter_db(engine, join, table, meta, controls, args, argstype) else: raise ValueError('No table: or query: specified') else: @@ -1679,6 +1680,7 @@ def _filter_frame( def _filter_db( engine: str, + join: dict, table: str, meta: dict, controls: dict, @@ -1698,6 +1700,41 @@ def _filter_db( argstype: optional dict that specifies `args` type and behavior. id: list of keys specific to data using which values can be updated ''' + + def get_joins(table, join): + tables = [table] + if not join: + return sa.select(tables) + + cols = [col.label(f"{table.name}_{col.name}") for col in table.columns] + join = json.loads(join) or {} + tables_map = {} + + for t in join.keys(): + tbl = get_table(engine, t) + tables = [tbl] + tables_map[t] = tbl + cols += [col.label(f"{table.name}_{col.name}") for col in tbl.columns] + + query = sa.select(*cols) + + # Establish an explicit left side by setting the main table as the base + query = query.select_from(table) + + for t, extras in join.items(): + joinAttr = [tables_map[t]] + if ("column" in extras): + condition = sa.text([f"{k}=={v}" for k, v in extras["column"].items()][0]) + joinAttr.append(condition) + + query = query.join( + *joinAttr, + isouter="type" in extras and extras["type"].lower() in ["left", "outer"], + ) + + return query + + table = get_table(engine, table) cols = table.columns colslist = cols.keys() @@ -1707,7 +1744,9 @@ def _filter_db( elif source == 'update': query = sa.update(table) else: - query = sa.select([table]) + # query = sa.select([table]) + query = get_joins(table, join) + cols_for_update = {} cols_having = [] for key, vals in args.items(): diff --git a/pytest/test_formhandler.py b/pytest/test_formhandler.py new file mode 100644 index 00000000..952ce1f2 --- /dev/null +++ b/pytest/test_formhandler.py @@ -0,0 +1,107 @@ +import os +import pytest +import gramex.data +import gramex.cache +from itertools import product +from contextlib import contextmanager +import pandas as pd +import dbutils +from pandas.testing import assert_frame_equal as afe +import json + + +folder = os.path.dirname(os.path.abspath(__file__)) +sales_join_file = os.path.join(folder, "..", "tests", "sales_join.xlsx") +sales_join_data: pd.DataFrame = gramex.cache.open(sales_join_file, sheet_name="sales") +customers_data: pd.DataFrame = gramex.cache.open(sales_join_file, sheet_name="customers") +products_data: pd.DataFrame = gramex.cache.open(sales_join_file, sheet_name="products") + +results = [{ + "kwargs" : { + "url": "", + "table": "sales", + }, + "expected": "SELECT * FROM sales", + "formatting": { + "sale_date": pd.Timestamp, + } +},{ + "kwargs" : { + "url": "", + "table": "sales", + "join": json.dumps({ + "products": { + "type": "inner", + "column": {"products.id": "sales.product_id"}, + }, + "customers": { + "type": "left", + "column": {"sales.customer_id": "customers.id"}, + }, + }), + }, + "expected": """ + SELECT + sales.id AS sales_id, + sales.customer_id AS sales_customer_id, + sales.product_id AS sales_product_id, + sales.sale_date AS sales_sale_date, + sales.amount AS sales_amount, + sales.city AS sales_city, + products.id AS sales_id, + products.name AS sales_name, + products.price AS sales_price, + products.manufacturer AS sales_manufacturer, + customers.id AS sales_id, + customers.name AS sales_name, + customers.city AS sales_city + FROM sales + JOIN products ON products.id==sales.product_id + LEFT OUTER JOIN customers ON sales.customer_id==customers.id + """, + "formatting": { + "sales_sale_date": pd.Timestamp, + } +# },{ +# "kwargs" : { +# "url": "", +# "table": "sales", +# "join": json.dumps({ +# "products": { +# "type": "inner", +# }, +# "customers": {}, +# }), +# }, +# "expected": 4, +}] + +@contextmanager +def sqlite(): + yield dbutils.sqlite_create_db( + "test_delete.db", + sales=sales_join_data, + customers=customers_data, + products=products_data, + ) + dbutils.sqlite_drop_db("test_delete.db") + + +db_setups = [ + # dataframe, + sqlite, + # mysql, + # postgres, +] + + +@pytest.mark.parametrize("result,db_setup", product(results, db_setups)) +def test_formhandler_join(result, db_setup): + kwargs = result["kwargs"] + with db_setup() as url: + kwargs["url"] = url + actual = gramex.data.filter(args=[], meta={}, **kwargs) + expected = pd.read_sql(result["expected"], url) + for k, v in result["formatting"].items(): + expected[k] = expected[k].apply(v) + afe(expected, actual) diff --git a/tests/sales_join.xlsx b/tests/sales_join.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..b7457e82c216840dd6dd4a0e98514e536ea207d5 GIT binary patch literal 13088 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 Date: Tue, 4 Apr 2023 18:36:37 +0530 Subject: [PATCH 02/14] FIX: comparision correction --- gramex/data.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/gramex/data.py b/gramex/data.py index 9edf5971..b50af111 100644 --- a/gramex/data.py +++ b/gramex/data.py @@ -1707,7 +1707,6 @@ def get_joins(table, join): return sa.select(tables) cols = [col.label(f"{table.name}_{col.name}") for col in table.columns] - join = json.loads(join) or {} tables_map = {} for t in join.keys(): @@ -1724,7 +1723,7 @@ def get_joins(table, join): for t, extras in join.items(): joinAttr = [tables_map[t]] if ("column" in extras): - condition = sa.text([f"{k}=={v}" for k, v in extras["column"].items()][0]) + condition = sa.text([f"{k}={v}" for k, v in extras["column"].items()][0]) joinAttr.append(condition) query = query.join( From 1664fb43df820884cb2daeece67f2e5fd47944dd Mon Sep 17 00:00:00 2001 From: Shraddheya Shrivastava Date: Tue, 4 Apr 2023 18:46:45 +0530 Subject: [PATCH 03/14] ENH: add test cases for formhandler join --- pytest/formhandler-basic/test-case.yaml | 6 ++ .../formhandler-join-controls/test-case.yaml | 29 +++++++ pytest/formhandler-join/test-case.yaml | 32 ++++++++ pytest/test_formhandler.py | 80 ++++--------------- 4 files changed, 81 insertions(+), 66 deletions(-) create mode 100644 pytest/formhandler-basic/test-case.yaml create mode 100644 pytest/formhandler-join-controls/test-case.yaml create mode 100644 pytest/formhandler-join/test-case.yaml diff --git a/pytest/formhandler-basic/test-case.yaml b/pytest/formhandler-basic/test-case.yaml new file mode 100644 index 00000000..709d5e67 --- /dev/null +++ b/pytest/formhandler-basic/test-case.yaml @@ -0,0 +1,6 @@ +kwargs: + url: "" + table: "sales" +expected: "SELECT * FROM sales" +formatting: + sale_date: to_datetime diff --git a/pytest/formhandler-join-controls/test-case.yaml b/pytest/formhandler-join-controls/test-case.yaml new file mode 100644 index 00000000..4dc77cde --- /dev/null +++ b/pytest/formhandler-join-controls/test-case.yaml @@ -0,0 +1,29 @@ +kwargs: + url: "" + table: sales + join: + products: + type: inner + column: + products.id: sales.product_id + customers: + type: left + column: + sales.customer_id: customers.id +args: + _c: + - "id|count" + _by: + - "customer_id" + customer_id>: + - '40' +expected: > + SELECT sales.id AS sales_id, + customer.id AS customer_id, + count(customer.id) as 'id|count' + FROM sales + JOIN products ON products.id = sales.product_id + LEFT OUTER JOIN customers ON sales.customer_id = customers.id + WHERE customer.id > 40 GROUP BY customer.id +formatting: + sales_sale_date: to_datetime diff --git a/pytest/formhandler-join/test-case.yaml b/pytest/formhandler-join/test-case.yaml new file mode 100644 index 00000000..8d64c35f --- /dev/null +++ b/pytest/formhandler-join/test-case.yaml @@ -0,0 +1,32 @@ +kwargs: + url: "" + table: sales + join: + products: + type: inner + column: + products.id: sales.product_id + customers: + type: left + column: + sales.customer_id: customers.id +expected: > + SELECT + sales.id AS sales_id, + sales.customer_id AS sales_customer_id, + sales.product_id AS sales_product_id, + sales.sale_date AS sales_sale_date, + sales.amount AS sales_amount, + sales.city AS sales_city, + products.id AS sales_id, + products.name AS sales_name, + products.price AS sales_price, + products.manufacturer AS sales_manufacturer, + customers.id AS sales_id, + customers.name AS sales_name, + customers.city AS sales_city + FROM sales + JOIN products ON products.id==sales.product_id + LEFT OUTER JOIN customers ON sales.customer_id==customers.id +formatting: + sales_sale_date: to_datetime diff --git a/pytest/test_formhandler.py b/pytest/test_formhandler.py index 952ce1f2..5d28e086 100644 --- a/pytest/test_formhandler.py +++ b/pytest/test_formhandler.py @@ -7,7 +7,7 @@ import pandas as pd import dbutils from pandas.testing import assert_frame_equal as afe -import json +from glob import glob folder = os.path.dirname(os.path.abspath(__file__)) @@ -16,65 +16,6 @@ customers_data: pd.DataFrame = gramex.cache.open(sales_join_file, sheet_name="customers") products_data: pd.DataFrame = gramex.cache.open(sales_join_file, sheet_name="products") -results = [{ - "kwargs" : { - "url": "", - "table": "sales", - }, - "expected": "SELECT * FROM sales", - "formatting": { - "sale_date": pd.Timestamp, - } -},{ - "kwargs" : { - "url": "", - "table": "sales", - "join": json.dumps({ - "products": { - "type": "inner", - "column": {"products.id": "sales.product_id"}, - }, - "customers": { - "type": "left", - "column": {"sales.customer_id": "customers.id"}, - }, - }), - }, - "expected": """ - SELECT - sales.id AS sales_id, - sales.customer_id AS sales_customer_id, - sales.product_id AS sales_product_id, - sales.sale_date AS sales_sale_date, - sales.amount AS sales_amount, - sales.city AS sales_city, - products.id AS sales_id, - products.name AS sales_name, - products.price AS sales_price, - products.manufacturer AS sales_manufacturer, - customers.id AS sales_id, - customers.name AS sales_name, - customers.city AS sales_city - FROM sales - JOIN products ON products.id==sales.product_id - LEFT OUTER JOIN customers ON sales.customer_id==customers.id - """, - "formatting": { - "sales_sale_date": pd.Timestamp, - } -# },{ -# "kwargs" : { -# "url": "", -# "table": "sales", -# "join": json.dumps({ -# "products": { -# "type": "inner", -# }, -# "customers": {}, -# }), -# }, -# "expected": 4, -}] @contextmanager def sqlite(): @@ -95,13 +36,20 @@ def sqlite(): ] -@pytest.mark.parametrize("result,db_setup", product(results, db_setups)) +@pytest.mark.parametrize( + "result,db_setup", product(glob(os.path.join(folder, "formhandler-*", "*.yaml")), db_setups) +) def test_formhandler_join(result, db_setup): - kwargs = result["kwargs"] + resJson = gramex.cache.open(result) + kwargs, formatting = resJson["kwargs"], resJson["formatting"] + args = [] + if 'args' in resJson: + args = resJson['args'] with db_setup() as url: kwargs["url"] = url - actual = gramex.data.filter(args=[], meta={}, **kwargs) - expected = pd.read_sql(result["expected"], url) - for k, v in result["formatting"].items(): - expected[k] = expected[k].apply(v) + expected = pd.read_sql(resJson["expected"], url) + actual = gramex.data.filter(args=args, meta={}, **kwargs) + for k, v in formatting.items(): + fun = getattr(pd, v) + expected[k] = expected[k].apply(fun) afe(expected, actual) From 00a13b9ccb61ab2c966a7f04e642ecd2ca682bd4 Mon Sep 17 00:00:00 2001 From: Shraddheya Shrivastava Date: Thu, 6 Apr 2023 11:46:43 +0530 Subject: [PATCH 04/14] FIX: join test fix the failing test because of malformed inputs in the yaml --- .../formhandler-join-controls/test-case.yaml | 13 +++++----- pytest/test_formhandler.py | 26 ++++++++++--------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/pytest/formhandler-join-controls/test-case.yaml b/pytest/formhandler-join-controls/test-case.yaml index 4dc77cde..ccf1acc6 100644 --- a/pytest/formhandler-join-controls/test-case.yaml +++ b/pytest/formhandler-join-controls/test-case.yaml @@ -16,14 +16,13 @@ args: _by: - "customer_id" customer_id>: - - '40' + - '3' expected: > - SELECT sales.id AS sales_id, - customer.id AS customer_id, - count(customer.id) as 'id|count' + SELECT + customers.id AS customer_id, + count(customers.id) as 'id|count' FROM sales JOIN products ON products.id = sales.product_id LEFT OUTER JOIN customers ON sales.customer_id = customers.id - WHERE customer.id > 40 GROUP BY customer.id -formatting: - sales_sale_date: to_datetime + WHERE customers.id > 3 + GROUP BY customers.id diff --git a/pytest/test_formhandler.py b/pytest/test_formhandler.py index 5d28e086..c03f9e54 100644 --- a/pytest/test_formhandler.py +++ b/pytest/test_formhandler.py @@ -20,12 +20,12 @@ @contextmanager def sqlite(): yield dbutils.sqlite_create_db( - "test_delete.db", + "test_formhandler_join.db", sales=sales_join_data, customers=customers_data, products=products_data, ) - dbutils.sqlite_drop_db("test_delete.db") + dbutils.sqlite_drop_db("test_formhandler_join.db") db_setups = [ @@ -37,19 +37,21 @@ def sqlite(): @pytest.mark.parametrize( - "result,db_setup", product(glob(os.path.join(folder, "formhandler-*", "*.yaml")), db_setups) + "result,db_setup", + product(glob(os.path.join(folder, "formhandler-*-contr*", "*.yaml")), db_setups), ) def test_formhandler_join(result, db_setup): resJson = gramex.cache.open(result) - kwargs, formatting = resJson["kwargs"], resJson["formatting"] args = [] - if 'args' in resJson: - args = resJson['args'] + if "args" in resJson: + args = resJson["args"] with db_setup() as url: - kwargs["url"] = url + resJson["kwargs"]["url"] = url + actual = gramex.data.filter(args=args, meta={}, **resJson["kwargs"]) expected = pd.read_sql(resJson["expected"], url) - actual = gramex.data.filter(args=args, meta={}, **kwargs) - for k, v in formatting.items(): - fun = getattr(pd, v) - expected[k] = expected[k].apply(fun) - afe(expected, actual) + if not expected.empty and "formatting" in resJson: + for k, v in resJson["formatting"].items(): + fun = getattr(pd, v) + expected[k] = expected[k].apply(fun) + + afe(expected, actual) From ef78555a155f22b95da3a25a19c4853c7b8110f2 Mon Sep 17 00:00:00 2001 From: Shraddheya Shrivastava Date: Thu, 6 Apr 2023 12:55:36 +0530 Subject: [PATCH 05/14] ENH: enable testing all database type to all tests --- pytest/test_formhandler.py | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/pytest/test_formhandler.py b/pytest/test_formhandler.py index c03f9e54..6dc14575 100644 --- a/pytest/test_formhandler.py +++ b/pytest/test_formhandler.py @@ -27,18 +27,47 @@ def sqlite(): ) dbutils.sqlite_drop_db("test_formhandler_join.db") +@contextmanager +def mysql(): + server = os.environ.get('MYSQL_SERVER', 'localhost') + yield dbutils.mysql_create_db( + server, + "test_formhandler_join", + sales=sales_join_data, + customers=customers_data, + products=products_data, + ) + dbutils.mysql_drop_db(server, "test_formhandler_join") + + +@contextmanager +def postgres(): + server = os.environ.get('POSTGRES_SERVER', 'localhost') + yield dbutils.postgres_create_db( + server, + "test_formhandler_join", + sales=sales_join_data, + customers=customers_data, + products=products_data, + ) + dbutils.postgres_drop_db(server, "test_formhandler_join") + +# @contextmanager +# def dataframe(): +# yield {'url': sales_join_data.copy()} + db_setups = [ # dataframe, sqlite, - # mysql, - # postgres, + mysql, + postgres, ] @pytest.mark.parametrize( "result,db_setup", - product(glob(os.path.join(folder, "formhandler-*-contr*", "*.yaml")), db_setups), + product(glob(os.path.join(folder, "formhandler-*", "*.yaml")), db_setups), ) def test_formhandler_join(result, db_setup): resJson = gramex.cache.open(result) From 1872b5c87c6818e7641111ba423ad061c788323d Mon Sep 17 00:00:00 2001 From: Shraddheya Shrivastava Date: Thu, 6 Apr 2023 12:56:11 +0530 Subject: [PATCH 06/14] ENH: include docker testing environment --- pytest/Docker-compose.yaml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 pytest/Docker-compose.yaml diff --git a/pytest/Docker-compose.yaml b/pytest/Docker-compose.yaml new file mode 100644 index 00000000..dde1d47e --- /dev/null +++ b/pytest/Docker-compose.yaml @@ -0,0 +1,25 @@ +version: '3.9' + +services: + + mysql: + image: mysql:8.0 + container_name: mysql + restart: always + environment: + MYSQL_ALLOW_EMPTY_PASSWORD: 'yes' + ports: + - 3306:3306 + expose: + - 3306 + + postgres: + image: postgres:13.2 + container_name: postgres + restart: always + environment: + POSTGRES_HOST_AUTH_METHOD: trust + ports: + - 5432:5432 + expose: + - 5432 From 76bbacb27b03272656b25bec5e0f5015a87be57c Mon Sep 17 00:00:00 2001 From: S Anand Date: Tue, 9 May 2023 14:00:03 +0530 Subject: [PATCH 07/14] FIX: minor issues --- gramex/data.py | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/gramex/data.py b/gramex/data.py index b50af111..545722cb 100644 --- a/gramex/data.py +++ b/gramex/data.py @@ -272,6 +272,7 @@ def filter( argstype=argstype, id=id, table=table, + join=join, columns=columns, ext=ext, query=query, @@ -314,7 +315,7 @@ def filter( data = gramex.cache.query(table, engine, [table]) return _filter_frame(transform(data), meta, controls, args, argstype) else: - return _filter_db(engine, join, table, meta, controls, args, argstype) + return _filter_db(engine, table, meta, controls, args, argstype, join=join) else: raise ValueError('No table: or query: specified') else: @@ -1680,7 +1681,6 @@ def _filter_frame( def _filter_db( engine: str, - join: dict, table: str, meta: dict, controls: dict, @@ -1688,6 +1688,7 @@ def _filter_db( argstype: Dict[str, dict] = {}, source: str = 'select', id: List[str] = None, + join: dict = None, ): ''' Parameters: @@ -1706,14 +1707,12 @@ def get_joins(table, join): if not join: return sa.select(tables) - cols = [col.label(f"{table.name}_{col.name}") for col in table.columns] + # Identify all tables and columns required + cols = [col.label(col.name) for col in table.columns] tables_map = {} - for t in join.keys(): - tbl = get_table(engine, t) - tables = [tbl] - tables_map[t] = tbl - cols += [col.label(f"{table.name}_{col.name}") for col in tbl.columns] + tables_map[t] = tbl = get_table(engine, t) + cols += [col.label(f'{t}_{col.name}') for col in tbl.columns] query = sa.select(*cols) @@ -1721,19 +1720,19 @@ def get_joins(table, join): query = query.select_from(table) for t, extras in join.items(): - joinAttr = [tables_map[t]] - if ("column" in extras): - condition = sa.text([f"{k}={v}" for k, v in extras["column"].items()][0]) - joinAttr.append(condition) + join_attr = [tables_map[t]] + if 'column' in extras: + # TODO: check + condition = sa.text([f'{k}={v}' for k, v in extras['column'].items()][0]) + join_attr.append(condition) query = query.join( - *joinAttr, - isouter="type" in extras and extras["type"].lower() in ["left", "outer"], + *join_attr, + isouter='type' in extras and extras['type'].lower() in ['left', 'outer'], ) return query - table = get_table(engine, table) cols = table.columns colslist = cols.keys() @@ -1743,9 +1742,8 @@ def get_joins(table, join): elif source == 'update': query = sa.update(table) else: - # query = sa.select([table]) query = get_joins(table, join) - + cols_for_update = {} cols_having = [] for key, vals in args.items(): From 943724e3e85a7599caaa8b30943be1d87fb72f76 Mon Sep 17 00:00:00 2001 From: Shraddheya Shrivastava Date: Wed, 10 May 2023 23:48:18 +0530 Subject: [PATCH 08/14] FIX: filter in join --- gramex/data.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/gramex/data.py b/gramex/data.py index 545722cb..39145069 100644 --- a/gramex/data.py +++ b/gramex/data.py @@ -1701,21 +1701,28 @@ def _filter_db( argstype: optional dict that specifies `args` type and behavior. id: list of keys specific to data using which values can be updated ''' - def get_joins(table, join): - tables = [table] if not join: - return sa.select(tables) + return table.columns, sa.select(tables) + + tables = [table] + cols = {} + labels = [] + for c in table.columns: + cols[c.name] = c + labels.append(c.label(c.name)) # Identify all tables and columns required - cols = [col.label(col.name) for col in table.columns] tables_map = {} for t in join.keys(): tables_map[t] = tbl = get_table(engine, t) - cols += [col.label(f'{t}_{col.name}') for col in tbl.columns] - - query = sa.select(*cols) + for c in tbl.columns: + lbl = f'{t}_{c.name}' + cols[lbl] = c + labels.append(c.label(lbl)) + # cols += [col.label(f'{t}_{col.name}') for col in tbl.columns] + query = sa.select(*labels) # Establish an explicit left side by setting the main table as the base query = query.select_from(table) @@ -1730,19 +1737,18 @@ def get_joins(table, join): *join_attr, isouter='type' in extras and extras['type'].lower() in ['left', 'outer'], ) - - return query + return cols, query table = get_table(engine, table) cols = table.columns colslist = cols.keys() - if source == 'delete': query = sa.delete(table) elif source == 'update': query = sa.update(table) else: - query = get_joins(table, join) + cols, query = get_joins(table, join) + colslist = list(cols.values()) cols_for_update = {} cols_having = [] From a2864c3098cf3fb5b78e3e4c49eb3cb8d6bb2729 Mon Sep 17 00:00:00 2001 From: Shraddheya Shrivastava Date: Thu, 11 May 2023 00:25:51 +0530 Subject: [PATCH 09/14] FIX: filter in join --- gramex/data.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/gramex/data.py b/gramex/data.py index 39145069..29405bd9 100644 --- a/gramex/data.py +++ b/gramex/data.py @@ -1748,7 +1748,7 @@ def get_joins(table, join): query = sa.update(table) else: cols, query = get_joins(table, join) - colslist = list(cols.values()) + colslist = list(cols.keys()) cols_for_update = {} cols_having = [] @@ -1756,6 +1756,8 @@ def get_joins(table, join): # check if `key` is in the `id` list -- ONLY when data is updated if (source == 'update' and key in id) or source in {'select', 'delete'}: # Parse column names, ignoring missing / unmatched columns + # if join: + # breakpoint() col, agg, op = _filter_col(key, colslist) if col is None: meta['ignored'].append((key, vals)) From 062ae8a6b54a3b6b79588c39689d894da0f9daff Mon Sep 17 00:00:00 2001 From: Shraddheya Shrivastava Date: Thu, 11 May 2023 00:29:58 +0530 Subject: [PATCH 10/14] FIX: remove redundant debug code --- gramex/data.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/gramex/data.py b/gramex/data.py index 29405bd9..8a9b6265 100644 --- a/gramex/data.py +++ b/gramex/data.py @@ -1756,8 +1756,6 @@ def get_joins(table, join): # check if `key` is in the `id` list -- ONLY when data is updated if (source == 'update' and key in id) or source in {'select', 'delete'}: # Parse column names, ignoring missing / unmatched columns - # if join: - # breakpoint() col, agg, op = _filter_col(key, colslist) if col is None: meta['ignored'].append((key, vals)) From 165c328dc0bd00918fc13363087d2309106d9025 Mon Sep 17 00:00:00 2001 From: S Anand Date: Sat, 27 May 2023 13:30:26 +0530 Subject: [PATCH 11/14] TST: Add a sample test case. Fix typo in data.py --- gramex/data.py | 5 ++--- tests/gramex.yaml | 12 ++++++++++++ tests/sales.xlsx | Bin 39828 -> 40769 bytes tests/test_formhandler.py | 17 +++++++++++++++-- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/gramex/data.py b/gramex/data.py index 8a9b6265..3e701712 100644 --- a/gramex/data.py +++ b/gramex/data.py @@ -1701,11 +1701,11 @@ def _filter_db( argstype: optional dict that specifies `args` type and behavior. id: list of keys specific to data using which values can be updated ''' + def get_joins(table, join): if not join: - return table.columns, sa.select(tables) + return table.columns, sa.select([table]) - tables = [table] cols = {} labels = [] for c in table.columns: @@ -1720,7 +1720,6 @@ def get_joins(table, join): lbl = f'{t}_{c.name}' cols[lbl] = c labels.append(c.label(lbl)) - # cols += [col.label(f'{t}_{col.name}') for col in tbl.columns] query = sa.select(*labels) # Establish an explicit left side by setting the main table as the base diff --git a/tests/gramex.yaml b/tests/gramex.yaml index 19c33a77..efa5f317 100644 --- a/tests/gramex.yaml +++ b/tests/gramex.yaml @@ -1200,6 +1200,18 @@ url: formats: json: date_format: iso + formhandler/join: + pattern: /formhandler/join + handler: FormHandler + kwargs: + url: sqlite:///formhandler.db + table: sales + join: + cities: + type: left + column: + sales.city: cities.city + sales.nonexistent: cities.nonexistent formhandler/dir: pattern: /formhandler/dir handler: FormHandler diff --git a/tests/sales.xlsx b/tests/sales.xlsx index 35e6af047ac1bf6a68cd1be70a97a7cb6377fba1..3583fec453001a98fcae7f5d7dda6bfeef5410de 100644 GIT binary patch delta 28203 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 delta 27317 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 XrMeRGvyur2MEbn+Jfph!|E&E#CS2tF diff --git a/tests/test_formhandler.py b/tests/test_formhandler.py index 00904831..1ac8d8c1 100644 --- a/tests/test_formhandler.py +++ b/tests/test_formhandler.py @@ -27,11 +27,12 @@ def copy_file(source, target): class TestFormHandler(TestGramex): - sales = gramex.cache.open(os.path.join(folder, 'sales.xlsx'), 'xlsx') + sales = gramex.cache.open(os.path.join(folder, 'sales.xlsx'), sheet_name='sales') + cities = gramex.cache.open(os.path.join(folder, 'sales.xlsx'), sheet_name='cities') @classmethod def setUpClass(cls): - dbutils.sqlite_create_db('formhandler.db', sales=cls.sales) + dbutils.sqlite_create_db('formhandler.db', sales=cls.sales, cities=cls.cities) @classmethod def tearDownClass(cls): @@ -834,6 +835,18 @@ def test_date_comparison(self): expected.index = actual.index afe(actual, expected, check_like=True) + def test_join(self): + def check(expected, **params): + r = self.get('/formhandler/join', params=params) + actual = pd.DataFrame(r.json()) + afe(actual, expected.reset_index(drop=True), check_like=True) + + expected = self.sales.merge(self.cities, how='left') + expected = expected.rename(columns={'demand': 'cities_demand', 'drive': 'cities_drive'}) + check(expected) + check(expected[expected['city'] == 'Singapore'], city='Singapore') + # TODO: Add remaining join tests + def test_edit_id_type(self): target = copy_file('sales.xlsx', 'sales-edits.xlsx') tempfiles[target] = target From e97cb1d3c47d46623243748d1b1564fd29f2382b Mon Sep 17 00:00:00 2001 From: Shraddheya Shrivastava Date: Wed, 31 May 2023 20:07:01 +0530 Subject: [PATCH 12/14] ADD: multi column join check for invalid columns in join remove redundant columns --- gramex/data.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/gramex/data.py b/gramex/data.py index 3e701712..b715ccd9 100644 --- a/gramex/data.py +++ b/gramex/data.py @@ -1708,9 +1708,11 @@ def get_joins(table, join): cols = {} labels = [] + label_texts = [] for c in table.columns: cols[c.name] = c labels.append(c.label(c.name)) + label_texts.append(f"{table.name}.{c.name}") # Identify all tables and columns required tables_map = {} @@ -1720,22 +1722,42 @@ def get_joins(table, join): lbl = f'{t}_{c.name}' cols[lbl] = c labels.append(c.label(lbl)) + label_texts.append(f'{t}.{c.name}') - query = sa.select(*labels) + query = sa.select() # Establish an explicit left side by setting the main table as the base query = query.select_from(table) for t, extras in join.items(): join_attr = [tables_map[t]] if 'column' in extras: - # TODO: check - condition = sa.text([f'{k}={v}' for k, v in extras['column'].items()][0]) + conditions = [] + for k, v in extras['column'].items(): + invalidColumns = [] + if k not in label_texts: + invalidColumns.append(k) + if v not in label_texts: + invalidColumns.append(v) + if len(invalidColumns) > 0: + app_log.warning(f'invalid column(s): {", ". join(invalidColumns)}') + continue + + conditions.append(f'{k}={v}') + labels = [ + l + for l in labels + if l.name not in [k.replace('.', '_'), v.replace('.', '_')] + ] + + condition = sa.text(' AND '.join(conditions)) join_attr.append(condition) query = query.join( *join_attr, isouter='type' in extras and extras['type'].lower() in ['left', 'outer'], ) + + query = query.with_only_columns(labels) return cols, query table = get_table(engine, table) From 1aad41e9711fd8e6156bafe54043c7bb1bfdadd3 Mon Sep 17 00:00:00 2001 From: Shraddheya Shrivastava Date: Thu, 1 Jun 2023 15:41:05 +0530 Subject: [PATCH 13/14] ADD: test cases to complete the functionality --- tests/test_formhandler.py | 48 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/tests/test_formhandler.py b/tests/test_formhandler.py index 1ac8d8c1..01df76c5 100644 --- a/tests/test_formhandler.py +++ b/tests/test_formhandler.py @@ -836,16 +836,58 @@ def test_date_comparison(self): afe(actual, expected, check_like=True) def test_join(self): - def check(expected, **params): - r = self.get('/formhandler/join', params=params) + def check(expected, *args, **params): + url = '/formhandler/join' + if args: + url += f'?{"&".join(args)}' + print(url) + params = {} + + r = self.get(url, params=params) + print("\n-----------\n", r.url) actual = pd.DataFrame(r.json()) + print("actual :\n", actual) + print("expected :\n", expected, "\n-----------\n") afe(actual, expected.reset_index(drop=True), check_like=True) expected = self.sales.merge(self.cities, how='left') expected = expected.rename(columns={'demand': 'cities_demand', 'drive': 'cities_drive'}) check(expected) check(expected[expected['city'] == 'Singapore'], city='Singapore') - # TODO: Add remaining join tests + check(expected[expected['sales'] != 500], **{"sales%33": '500'}) + check(expected[expected['sales'] > 500], **{"sales>": '500'}) + check(expected[expected['sales'] >= 500], **{"sales>~": '500'}) + check(expected[expected['sales'] < 500], **{"sales<": '500'}) + check(expected[expected['sales'] <= 500], **{"sales<~": '500'}) + check( + expected[expected['cities_demand'] > 400].sort_values(by='product'), + **{"cities_demand>": '400', "_sort": 'product'}, + ) + check( + expected[expected['cities_demand'] > 400].sort_values(by='product', ascending=False), + **{"cities_demand>": '400', "_sort": '-product'}, + ) + check( + # FIXME: we should not have to rename the columns, the column name must always be same + expected[['sales', 'growth', 'cities_drive']].rename( + columns={'cities_drive': 'drive'} + ), + "_c=sales", + "_c=growth", + "_c=cities_drive", + ) + # check( + # # FIXME: Test Failing + # expected.drop(['sales', 'growth', 'cities_drive'], axis=1), + # "_c=-sales", + # "_c=-growth", + # "_c=-cities_drive", + # ) + check(expected.dropna(subset=['sales']), "sales") + check( + expected[expected['sales'].isna()].applymap(lambda x: None if pd.isnull(x) else x), + "sales!", + ) def test_edit_id_type(self): target = copy_file('sales.xlsx', 'sales-edits.xlsx') From 9e8a2d1303dce8d1af43ba801ca9052016e47049 Mon Sep 17 00:00:00 2001 From: Shraddheya Shrivastava Date: Thu, 1 Jun 2023 16:11:10 +0530 Subject: [PATCH 14/14] ENH: Remove debug statements --- tests/test_formhandler.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/test_formhandler.py b/tests/test_formhandler.py index 01df76c5..256fe1d7 100644 --- a/tests/test_formhandler.py +++ b/tests/test_formhandler.py @@ -840,14 +840,10 @@ def check(expected, *args, **params): url = '/formhandler/join' if args: url += f'?{"&".join(args)}' - print(url) params = {} r = self.get(url, params=params) - print("\n-----------\n", r.url) actual = pd.DataFrame(r.json()) - print("actual :\n", actual) - print("expected :\n", expected, "\n-----------\n") afe(actual, expected.reset_index(drop=True), check_like=True) expected = self.sales.merge(self.cities, how='left')