-
Notifications
You must be signed in to change notification settings - Fork 5
Add support for LISTOF function, array return, and EXPLODE operator in PyDough #523
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3420f45
a9a5c6c
c7ec035
e61265b
baf47cb
91a18cc
766f2ea
6a1d179
6c89f4e
4ecdaf4
f6f0877
99098a0
4120c9b
df06954
3be7bd3
d2057e4
3f6fb6f
2bdf031
d0473e5
ac158cb
e72bb2f
10ac9d9
df4308e
c727d6b
ac53caf
799534d
cea2df7
2462791
314c7d4
9edcbf2
f375231
24de864
4a077e5
4910fc5
dafd2c1
84037af
51517f6
069ad2e
8c57b74
ad51c98
ce0979d
2d60647
4ff2736
2adbb90
c8f706b
f6b2431
c80670c
4871bf9
af9b38c
c7f9979
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -19,6 +19,7 @@ This page describes the specification of the PyDough DSL. The specification incl | |||||
| * [SINGULAR](#singular) | ||||||
| * [BEST](#best) | ||||||
| * [CROSS](#cross) | ||||||
| * [EXPLODE](#explode) | ||||||
| - [User Generated Collections](#user-generated-collections) | ||||||
| * [range_collection](#range_collection) | ||||||
| * [dataframe_collection](#dataframe_collection) | ||||||
|
|
@@ -36,7 +37,7 @@ This page describes the specification of the PyDough DSL. The specification incl | |||||
| ## Example Graph | ||||||
|
|
||||||
| The examples in this document use a metadata graph (named `GRAPH`) with the following collections: | ||||||
| - `People`: records of every known person. Scalar properties: `first_name`, `middle_name`, `last_name`, `ssn`, `birth_date`, `email`, `current_address_id`. | ||||||
| - `People`: records of every known person. Scalar properties: `first_name`, `middle_name`, `last_name`, `ssn`, `birth_date`, `email`, `current_address_id`, `phone_numbers`. | ||||||
| - `Addresses`: records of every known address. Scalar properties: `address_id`, `street_number`, `street_name`, `apartment`, `zip_code`, `city`, `state`. | ||||||
| - `Packages`: records of every known package. Scalar properties: `package_id`, `customer_ssn`, `shipping_address_id`, `billing_address_id`, `order_date`, `arrival_date`, `package_cost`. | ||||||
|
|
||||||
|
|
@@ -1536,6 +1537,147 @@ People.CALCULATE(Packages=COUNT(People.packages)).CROSS(Packages) | |||||
| People.CROSS(Addresses).current_address | ||||||
| ``` | ||||||
|
|
||||||
| <!-- TOC --><a name="explode"></a> | ||||||
| ### EXPLODE | ||||||
|
|
||||||
| A PyDough operation that explodes each row from a collection into multiple rows, i.e. from flattening a column of array data, or by splitting up a string column on a delimiter. The outputted collection will be a sub-collection of the original context containing the exploded data, and an optional indexed column keeping track of the indices of each value of the exploded data within a single row. This newly generated sub-collection has no other sub-collections from the original collection. The syntax for this operation is `collection.EXPLODE(...)`. `EXPLODE` has the following arguments: | ||||||
| - `data` (required): the expression from the current context being exploded (either an array or string). This expression cannot reference any sub-collections of the current context. | ||||||
| - `name` (required): the name of the collection created from the explosion operation (similar to `PARTITION`). | ||||||
| - `value_name` (required): a string literal declaring the name of the new column that will be used to store the exploded data. | ||||||
| - `index_name` (optional): a string literal declaring the name of the new column that will be used to store the indices of the exploded data. If not provided, this column is not generated. The `index_name` is required if `is_distinct` is False. The indices are 0-indexed. | ||||||
| - `version` (optional, default=`"array"`): either `"array"` or `"string"`, stating whether the data to explode is an array being flattened or a string being split on a delimiter. | ||||||
| - `delimiter` (optional): a string literal indicating the delimiter that should be used to split up the string if `version="string"`. If `delimiter` is an empty string, the string will be split into individual characters. | ||||||
| - `filtering` (optional, default=`True`): `True` if it is possible for not every row in the original collection to be preserved in the exploded sub-collection (i.e. if one of the arrays is empty), and `False` otherwise. | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I find this one kind of confusing. Will this filter on certain conditions or will be more like filtering Empty/None values? Assuming the second one, the description could be something as follow:
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's not what this does. It is just a boolean telling PyDough whether the operation will potentially remove rows (e.g., CAN any of the rows from the original be an empty array. |
||||||
| - `is_distinct` (optional, default=`False`): `True` if each row of the exploded data is unique within the set of all other values from that same original row of unexploded data, and `False` otherwise. An `index_name` can only be omitted if `is_distinct` is `True`. | ||||||
|
|
||||||
| > [!IMPORTANT] | ||||||
| > This feature is only supported in certain dialects. It is currently supported for Snowflake, DataBricks, DuckDB, Postgres and Trino. | ||||||
|
|
||||||
| **Good Example #1**: List out every phone number had by every person (assuming `phone_numbers` is an array of strings). | ||||||
|
knassre-bodo marked this conversation as resolved.
|
||||||
|
|
||||||
| ```py | ||||||
| %%pydough | ||||||
| People.EXPLODE(phone_numbers, "numbers", value_name='phone_number', is_distinct=True) | ||||||
| ``` | ||||||
|
|
||||||
| **Good Example #2**: For each person, find the first phone number they have (assuming `phone_numbers` is an array of strings). | ||||||
|
|
||||||
| ```py | ||||||
| %%pydough | ||||||
| People.CALCULATE(first_name, last_name) | ||||||
| .EXPLODE(phone_numbers, "numbers", value_name='phone_number', index_name='idx', is_distinct=True) | ||||||
| .WHERE(idx == 0) | ||||||
| .CALCULATE(first_name, last_name, phone_number) | ||||||
| ``` | ||||||
|
|
||||||
| **Good Example #3**: For each person, count how many phone numbers they have (assuming `phone_numbers` is an array of strings). | ||||||
| ```py | ||||||
| %%pydough | ||||||
| exploded_numbers = EXPLODE(phone_numbers, "numbers", value_name='phone_number', is_distinct=True) | ||||||
| People.CALCULATE(first_name, last_name, n_phone_numbers=COUNT(exploded_numbers)) | ||||||
| ``` | ||||||
|
|
||||||
| **Good Example #4**: List out every phone number had by every person (assuming `phone_numbers` is a string of comma separated values). | ||||||
|
|
||||||
| ```py | ||||||
| %%pydough | ||||||
| People.EXPLODE(phone_numbers, "numbers", value_name='phone_number', version="string", delimiter=",", is_distinct=True) | ||||||
| ``` | ||||||
|
|
||||||
| **Good Example #5**: List the number of times each character of the alphabet used within first names of people. | ||||||
|
|
||||||
| ```py | ||||||
| %%pydough | ||||||
| People.EXPLODE(LOWER(first_name), "characters", value_name='char', index_name="idx", version="string", delimiter="") | ||||||
| .PARTITION(name='letters', by=char) | ||||||
| .CALCULATE(char, n_uses=COUNT(characters)) | ||||||
| ``` | ||||||
|
|
||||||
| **Data Example**: | ||||||
|
|
||||||
| Suppose we have the following collection of data `thesaurus` containing words and some of their synonyms in an array: | ||||||
| | word | synonyms | | ||||||
| |---------|--------------------------------| | ||||||
| | 'wise' | ['sage', 'insightful', 'keen'] | | ||||||
| | 'old' | ['elderly', 'ancient'] | | ||||||
| | 'my' | [] | | ||||||
| | 'large' | ['big'] | | ||||||
|
|
||||||
| Now suppose the following PyDough code is used to transform `thesaurus` using the `EXPLODE` operator (note: `filtering=True` because one of the rows is an empty array): | ||||||
| ```py | ||||||
| %%pydough | ||||||
| thesaurus.CALCULATE(word) | ||||||
| .EXPLODE(synonyms, "words", value_name='synonym', index_name='syn_idx', filtering=True) | ||||||
| .CALCULATE(word, syn_idx, synonym) | ||||||
| ``` | ||||||
|
|
||||||
| The result would be the following table: | ||||||
| | word | syn_idx | synonym | | ||||||
| |---------|---------|--------------| | ||||||
| | 'wise' | 0 | 'sage' | | ||||||
| | 'wise' | 1 | 'insightful' | | ||||||
| | 'wise' | 2 | 'keen' | | ||||||
| | 'old' | 0 | 'elderly' | | ||||||
| | 'old' | 1 | 'ancient' | | ||||||
| | 'large' | 0 | 'big' | | ||||||
|
|
||||||
| **Bad Example #1**: Missing the `name`. | ||||||
|
|
||||||
| ```py | ||||||
| %%pydough | ||||||
| People.EXPLODE(phone_numbers, value_name='phone_number', index_name="idx") | ||||||
| ``` | ||||||
|
|
||||||
| **Bad Example #2**: Missing the `value_name`. | ||||||
|
|
||||||
| ```py | ||||||
| %%pydough | ||||||
| People.EXPLODE(phone_numbers, "numbers", index_name="idx") | ||||||
| ``` | ||||||
|
|
||||||
| **Bad Example #3**: Missing the `index_name` when `is_distinct=True` is not provided. | ||||||
|
|
||||||
| ```py | ||||||
| %%pydough | ||||||
| People.EXPLODE(phone_numbers, "numbers", value_name='phone_number') | ||||||
| ``` | ||||||
|
|
||||||
| **Bad Example #4**: Providing an invalid `version` | ||||||
|
|
||||||
| ```py | ||||||
| %%pydough | ||||||
| People.EXPLODE(phone_numbers, "numbers", value_name='phone_number', index_name="idx", version="party") | ||||||
| ``` | ||||||
|
|
||||||
| **Bad Example #5**: Missing the `delimiter` when `version="string"` | ||||||
|
|
||||||
| ```py | ||||||
| %%pydough | ||||||
| People.EXPLODE(phone_numbers, "numbers", value_name='phone_number', index_name="idx", version="string") | ||||||
| ``` | ||||||
|
|
||||||
| **Bad Example #6**: Not providing a string literal for the delimiter. | ||||||
|
|
||||||
| ```py | ||||||
| %%pydough | ||||||
| People.EXPLODE(phone_numbers, "numbers", value_name='phone_number', index_name="idx", version="string", delimiter=first_name) | ||||||
| ``` | ||||||
|
|
||||||
| **Bad Example #7**: Attempting to access a sub-collection after exploding. | ||||||
|
|
||||||
| ```py | ||||||
| %%pydough | ||||||
| People.EXPLODE(phone_numbers, "numbers", value_name='phone_number', index_name="idx") | ||||||
| .packages | ||||||
| ``` | ||||||
|
|
||||||
| **Bad Example #8**: Accessing a sub-collection when declaring the data to explode. | ||||||
|
|
||||||
| ```py | ||||||
| %%pydough | ||||||
| People.EXPLODE(LISTOF(packages.package_cost), "costs", value_name='cost', index_name="idx") | ||||||
| ``` | ||||||
|
|
||||||
| <!-- TOC --><a name="user-generated-collections"></a> | ||||||
| ## User Generated Collections | ||||||
|
|
||||||
|
|
@@ -1605,12 +1747,17 @@ The supported PyDough types for `dataframe_collection` are: | |||||
| - `NumericType`: includes float, integer, infinity, Nan. | ||||||
| - `BooleanType`: True or False. | ||||||
| - `StringType`: alphanumeric characters. | ||||||
| - `Datetype`: date and datetime. | ||||||
| - `DateType`: date and datetime. | ||||||
| - `ArrayType`: arrays of data. | ||||||
| - `UnknownType`: used for all `None` columns. | ||||||
|
|
||||||
| Note: MySQL by default does not support infinity values. When PyDough detects | ||||||
| infinity value with `DatabaseDiatect.MYSQL` an error will be raised. | ||||||
|
|
||||||
| > [!IMPORTANT] | ||||||
| > `ArrayType` is only supported for certain dialects: Trino, Postgres, DuckDB, Databricks. | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What about Snowflake? I saw it among the supported dialects for the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Array literals aren't a thing in Snowflake; you cannot put arrays inside |
||||||
| > Postgres has a limited ability to support rows with an empty array, depending on the type of the column. These sorts of array literals are only supported when the overall column type is an array of booleans, numbers, strings, or datetime values. | ||||||
|
|
||||||
| #### Example 1 | ||||||
|
|
||||||
| ```python | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -72,6 +72,7 @@ Below is the list of every function/operator currently supported in PyDough as a | |
| * [HASNOT](#hasnot) | ||
| * [VAR](#var) | ||
| * [STD](#std) | ||
| * [LISTOF](#listof) | ||
| - [Window Functions](#window-functions) | ||
| * [RANKING](#ranking) | ||
| * [PERCENTILE](#percentile) | ||
|
|
@@ -1089,6 +1090,34 @@ Parts.CALCULATE(std = STD(supply_records.supply_cost)) | |
| Parts.CALCULATE(std = STD(supply_records.supply_cost, type="sample")) | ||
| ``` | ||
|
|
||
| <!-- TOC --><a name="std"></a> | ||
|
knassre-bodo marked this conversation as resolved.
|
||
|
|
||
| ### LISTOF | ||
|
|
||
| The `LISTOF` function collects a set of values into an array. | ||
|
|
||
| > [!IMPORTANT] | ||
| > This function is only supported in certain dialects. It is currently supported for Snowflake, DataBricks, DuckDB, Postgres and Trino. | ||
|
|
||
| ```py | ||
| # For each region, list the names of all nations inside that region | ||
| Regions.CALCULATE(region_name=name, nation_names=LISTOF(nations.name)) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we add how the result for each of the good example would look like?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For the regions, sure. The other one is too big. |
||
|
|
||
| # For each customer, list the three largest quantities purchased made by that | ||
| # customer. | ||
| selected_lines = orders.lines.best(by=quantity.DESC(), per='Customers', n_best=3) | ||
| Customers.CALCULATE(customer_name=name, quantities=LISTOF(selected_lines.quantity)) | ||
| ``` | ||
|
|
||
| The first of these examples would produce the following output: | ||
| | region_name | nation_names | | ||
| |---------------|---------------------------------------------------------------| | ||
| | "AFRICA" | ["ALGERIA", "ETHIOPIA", "KENYA", "MOROCCO", "MOZAMBIQUE"] | | ||
| | "AMERICA" | ["ARGENTINA", "BRAZIL", "CANADA", "PERU", "UNITED STATES"] | | ||
| | "ASIA" | ["INDIA", "INDONESIA", "JAPAN", "CHINA", "VIETNAM"] | | ||
| | "EUROPE" | ["FRANCE", "GERMANY", "ROMANIA", "RUSSIA", "UNITED KINGDOM"] | | ||
| | "MIDDLE EAST" | ["EGYPT", "IRAN", "IRAQ", "JORDAN", "SAUDI ARABIA"] | | ||
|
|
||
| <!-- TOC --><a name="window-functions"></a> | ||
|
|
||
| ## Window Functions | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this argument really needed? Can't we just check the type of data?
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No because our typing system is not fully robust/reliable