diff --git a/Makefile b/Makefile index 0de05cb3..9334d973 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,11 @@ clean_infra: docker-compose down --remove-orphans &&\ docker system prune -a --volumes -f -run_tests: tests +stop_infra: + cd infra &&\ + docker-compose down --remove-orphans + +run_tests: run_infra sleep tests run_unasync: poetry run unasync postgrest tests @@ -31,3 +35,6 @@ build_sync: run_unasync remove_pytest_asyncio_from_sync remove_pytest_asyncio_from_sync: sed -i 's/@pytest.mark.asyncio//g' tests/_sync/test_client.py + +sleep: + sleep 5 diff --git a/infra/docker-compose.yaml b/infra/docker-compose.yaml index cdca0d08..5be08072 100644 --- a/infra/docker-compose.yaml +++ b/infra/docker-compose.yaml @@ -20,6 +20,6 @@ services: POSTGRES_USER: app_user POSTGRES_PASSWORD: password # Uncomment this if you want to persist the data. Create your boostrap SQL file in the project root -# volumes: -# - "./pgdata:/var/lib/postgresql/data" -# - "./init.sql:/docker-entrypoint-initdb.d/init.sql" + volumes: + # - "./pgdata:/var/lib/postgresql/data" + - "./init.sql:/docker-entrypoint-initdb.d/init.sql" diff --git a/infra/init.sql b/infra/init.sql new file mode 100644 index 00000000..3aad2eb2 --- /dev/null +++ b/infra/init.sql @@ -0,0 +1,71 @@ +CREATE TABLE public.countries ( + id int8 PRIMARY KEY, + iso CHAR (2) NOT NULL, + country_name VARCHAR (80) NOT NULL, + nicename VARCHAR (80) NOT NULL, + iso3 CHAR (3) DEFAULT NULL, + numcode SMALLINT DEFAULT NULL, + phonecode INT NOT NULL +); + +INSERT INTO public.countries (id, iso, country_name, nicename, iso3, numcode, phonecode) VALUES + (1, 'AF', 'AFGHANISTAN', 'Afghanistan', 'AFG', 4, 93), + (2, 'AL', 'ALBANIA', 'Albania', 'ALB', 8, 355), + (3, 'DZ', 'ALGERIA', 'Algeria', 'DZA', 12, 213), + (4, 'AQ', 'ANTARCTICA', 'Antarctica', NULL, NULL, 0), + (5, 'CR', 'COSTA RICA', 'Costa Rica', 'CRI', 188, 506), + (6, 'ES', 'SPAIN', 'Spain', 'ESP', 724, 34), + (7, 'TH', 'THAILAND', 'Thailand', 'THA', 764, 66), + (8, 'TG', 'TOGO', 'Togo', 'TGO', 768, 228), + (9, 'TT', 'TRINIDAD AND TOBAGO', 'Trinidad and Tobago', 'TTO', 780, 1868), + (10, 'GB', 'UNITED KINGDOM', 'United Kingdom', 'GBR', 826, 44), + (11, 'US', 'UNITED STATES', 'United States', 'USA', 840, 1), + (12, 'ZW', 'ZIMBABWE', 'Zimbabwe', 'ZWE', 716, 263); + +create table public.cities ( + id int8 primary key, + country_id int8 not null references public.countries, + name text +); + +insert into public.cities (id, name, country_id) values + (1, 'London', 10), + (2, 'Manchester', 10), + (3, 'Liverpool', 10), + (4, 'Bristol', 10), + (5, 'Miami', 11), + (6, 'Huston', 11), + (7, 'Atlanta', 11); + +create table public.users ( + id int8 primary key, + name text, + address jsonb +); + +insert into public.users (id, name, address) values + (1, 'Michael', '{ "postcode": 90210, "street": "Melrose Place" }'), + (2, 'Jane', '{}'); + +create table public.reservations ( + id int8 primary key, + room_name text, + during tsrange +); + +insert into public.reservations (id, room_name, during) values + (1, 'Emerald', '[2000-01-01 13:00, 2000-01-01 15:00)'), + (2, 'Topaz', '[2000-01-02 09:00, 2000-01-02 10:00)'); + + +create table public.issues ( + id int8 primary key, + title text, + tags text[] +); + +insert into public.issues (id, title, tags) values + (1, 'Cache invalidation is not working', array['is:open', 'severity:high', 'priority:low']), + (2, 'Use better names', array['is:open', 'severity:low', 'priority:medium']), + (3, 'Add missing postgrest filters', array['is:open', 'severity:low', 'priority:high']), + (4, 'Add alias to filters', array['is:closed', 'severity:low', 'priority:medium']); diff --git a/poetry.lock b/poetry.lock index 07edf35d..1829aed1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1145,13 +1145,13 @@ pytest = ">=3" [[package]] name = "python-gitlab" -version = "4.3.0" +version = "4.4.0" description = "A python wrapper for the GitLab API" optional = false python-versions = ">=3.8.0" files = [ - {file = "python-gitlab-4.3.0.tar.gz", hash = "sha256:eb31d1f2bfd8653f74996f9d0bf84ce7afb0843f9122a257c9a93b0e027d1df0"}, - {file = "python_gitlab-4.3.0-py3-none-any.whl", hash = "sha256:cc1dc49c562c02ffbad3656e668234c45ea6210688ade59865b284313f45000d"}, + {file = "python-gitlab-4.4.0.tar.gz", hash = "sha256:1d117bf7b433ae8255e5d74e72c660978f50ee85eb62248c9fb52ef43c3e3814"}, + {file = "python_gitlab-4.4.0-py3-none-any.whl", hash = "sha256:cdad39d016f59664cdaad0f878f194c79cb4357630776caa9a92c1da25c8d986"}, ] [package.dependencies] diff --git a/postgrest/base_request_builder.py b/postgrest/base_request_builder.py index 303fe2be..cd84acf3 100644 --- a/postgrest/base_request_builder.py +++ b/postgrest/base_request_builder.py @@ -330,6 +330,17 @@ def ilike(self: Self, column: str, pattern: str) -> Self: """ return self.filter(column, Filters.ILIKE, pattern) + def or_(self: Self, filters: str, reference_table: Union[str, None] = None) -> Self: + """An 'or' filter + + Args: + filters: The filters to use, following PostgREST syntax + reference_table: Set this to filter on referenced tables instead of the parent table + """ + key = f"{sanitize_param(reference_table)}.or" if reference_table else "or" + self.params = self.params.add(key, f"({filters})") + return self + def fts(self: Self, column: str, query: Any) -> Self: return self.filter(column, Filters.FTS, query) diff --git a/pyproject.toml b/pyproject.toml index 6a8981c4..c0c86b40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "postgrest" version = "0.13.2" description = "PostgREST client for Python. This library provides an ORM interface to PostgREST." -authors = ["Lương Quang Mạnh ", "Joel Lee ", "Anand", "Oliver Rice"] +authors = ["Lương Quang Mạnh ", "Joel Lee ", "Anand", "Oliver Rice", "Andrew Smith "] homepage = "https://github.com/supabase-community/postgrest-py" repository = "https://github.com/supabase-community/postgrest-py" documentation = "https://postgrest-py.rtfd.io" diff --git a/tests/_async/client.py b/tests/_async/client.py new file mode 100644 index 00000000..cb97e6d0 --- /dev/null +++ b/tests/_async/client.py @@ -0,0 +1,9 @@ +from postgrest import AsyncPostgrestClient + +REST_URL = "http://127.0.0.1:3000" + + +def rest_client(): + return AsyncPostgrestClient( + base_url=REST_URL, + ) diff --git a/tests/_async/test_filter_request_builder.py b/tests/_async/test_filter_request_builder.py index 3e1b9abf..a8ead1b1 100644 --- a/tests/_async/test_filter_request_builder.py +++ b/tests/_async/test_filter_request_builder.py @@ -200,3 +200,18 @@ def test_in_(filter_request_builder): builder = filter_request_builder.in_("x", ["a", "b"]) assert str(builder.params) == "x=in.%28a%2Cb%29" + + +def test_or_(filter_request_builder): + builder = filter_request_builder.or_("x.eq.1") + + assert str(builder.params) == "or=%28x.eq.1%29" + + +def test_or_in_contain(filter_request_builder): + builder = filter_request_builder.or_("id.in.(5,6,7), arraycol.cs.{'a','b'}") + + assert ( + str(builder.params) + == "or=%28id.in.%285%2C6%2C7%29%2C%20arraycol.cs.%7B%27a%27%2C%27b%27%7D%29" + ) diff --git a/tests/_async/test_filter_request_builder_integration.py b/tests/_async/test_filter_request_builder_integration.py new file mode 100644 index 00000000..14a651ab --- /dev/null +++ b/tests/_async/test_filter_request_builder_integration.py @@ -0,0 +1,335 @@ +from .client import rest_client + + +async def test_multivalued_param(): + res = ( + await rest_client() + .from_("countries") + .select("country_name, iso", count="exact") + .lte("numcode", 8) + .gte("numcode", 4) + .execute() + ) + + assert res.count == 2 + assert res.data == [ + {"country_name": "AFGHANISTAN", "iso": "AF"}, + {"country_name": "ALBANIA", "iso": "AL"}, + ] + + +async def test_match(): + res = ( + await rest_client() + .from_("countries") + .select("country_name, iso") + .match({"numcode": 8, "nicename": "Albania"}) + .single() + .execute() + ) + + assert res.data == {"country_name": "ALBANIA", "iso": "AL"} + + +async def test_equals(): + res = ( + await rest_client() + .from_("countries") + .select("country_name, iso") + .eq("nicename", "Albania") + .single() + .execute() + ) + + assert res.data == {"country_name": "ALBANIA", "iso": "AL"} + + +async def test_not_equal(): + res = ( + await rest_client() + .from_("users") + .select("id, name") + .neq("name", "Jane") + .single() + .execute() + ) + + assert res.data == {"id": 1, "name": "Michael"} + + +async def test_greater_than(): + res = ( + await rest_client() + .from_("users") + .select("id, name") + .gt("id", 1) + .single() + .execute() + ) + + assert res.data == {"id": 2, "name": "Jane"} + + +async def test_greater_than_or_equals_to(): + res = await rest_client().from_("users").select("id, name").gte("id", 1).execute() + + assert res.data == [{"id": 1, "name": "Michael"}, {"id": 2, "name": "Jane"}] + + +async def test_contains_dictionary(): + res = ( + await rest_client() + .from_("users") + .select("name") + .contains("address", {"postcode": 90210}) + .single() + .execute() + ) + + assert res.data == {"name": "Michael"} + + +async def test_contains_any_item(): + res = ( + await rest_client() + .from_("issues") + .select("title") + .contains("tags", ["is:open", "priority:low"]) + .execute() + ) + + assert res.data == [{"title": "Cache invalidation is not working"}] + + +async def test_contains_on_range(): + res = ( + await rest_client() + .from_("reservations") + .select("id, room_name") + .contains("during", "[2000-01-01 13:00, 2000-01-01 13:30)") + .execute() + ) + + assert res.data == [{"id": 1, "room_name": "Emerald"}] + + +async def test_contained_by_mixed_items(): + res = ( + await rest_client() + .from_("reservations") + .select("id, room_name") + .contained_by("during", "[2000-01-01 00:00, 2000-01-01 23:59)") + .execute() + ) + + assert res.data == [{"id": 1, "room_name": "Emerald"}] + + +async def test_range_greater_than(): + res = ( + await rest_client() + .from_("reservations") + .select("id, room_name") + .range_gt("during", ["2000-01-02 08:00", "2000-01-02 09:00"]) + .execute() + ) + + assert res.data == [{"id": 2, "room_name": "Topaz"}] + + +async def test_range_greater_than_or_equal_to(): + res = ( + await rest_client() + .from_("reservations") + .select("id, room_name") + .range_gte("during", ["2000-01-02 08:30", "2000-01-02 09:30"]) + .execute() + ) + + assert res.data == [{"id": 2, "room_name": "Topaz"}] + + +async def test_range_less_than(): + res = ( + await rest_client() + .from_("reservations") + .select("id, room_name") + .range_lt("during", ["2000-01-01 15:00", "2000-01-02 16:00"]) + .execute() + ) + + assert res.data == [{"id": 1, "room_name": "Emerald"}] + + +async def test_range_less_than_or_equal_to(): + res = ( + await rest_client() + .from_("reservations") + .select("id, room_name") + .range_lte("during", ["2000-01-01 14:00", "2000-01-01 16:00"]) + .execute() + ) + + assert res.data == [{"id": 1, "room_name": "Emerald"}] + + +async def test_range_adjacent(): + res = ( + await rest_client() + .from_("reservations") + .select("id, room_name") + .range_adjacent("during", ["2000-01-01 12:00", "2000-01-01 13:00"]) + .execute() + ) + + assert res.data == [{"id": 1, "room_name": "Emerald"}] + + +async def test_overlaps(): + res = ( + await rest_client() + .from_("issues") + .select("title") + .overlaps("tags", ["is:closed", "severity:high"]) + .execute() + ) + + assert res.data == [ + {"title": "Cache invalidation is not working"}, + {"title": "Add alias to filters"}, + ] + + +async def test_like(): + res = ( + await rest_client() + .from_("countries") + .select("country_name, iso") + .like("nicename", "%Alba%") + .execute() + ) + + assert res.data == [{"country_name": "ALBANIA", "iso": "AL"}] + + +async def test_ilike(): + res = ( + await rest_client() + .from_("countries") + .select("country_name, iso") + .ilike("nicename", "%alban%") + .execute() + ) + + assert res.data == [{"country_name": "ALBANIA", "iso": "AL"}] + + +async def test_is_(): + res = ( + await rest_client() + .from_("countries") + .select("country_name, iso") + .is_("numcode", "null") + .limit(1) + .order("nicename") + .execute() + ) + + assert res.data == [{"country_name": "ANTARCTICA", "iso": "AQ"}] + + +async def test_is_not(): + res = ( + await rest_client() + .from_("countries") + .select("country_name, iso") + .not_.is_("numcode", "null") + .limit(1) + .order("nicename") + .execute() + ) + + assert res.data == [{"country_name": "AFGHANISTAN", "iso": "AF"}] + + +async def test_in_(): + res = ( + await rest_client() + .from_("countries") + .select("country_name, iso") + .in_("nicename", ["Albania", "Algeria"]) + .execute() + ) + + assert res.data == [ + {"country_name": "ALBANIA", "iso": "AL"}, + {"country_name": "ALGERIA", "iso": "DZ"}, + ] + + +async def test_or_(): + res = ( + await rest_client() + .from_("countries") + .select("country_name, iso") + .or_("iso.eq.DZ,nicename.eq.Albania") + .execute() + ) + + assert res.data == [ + {"country_name": "ALBANIA", "iso": "AL"}, + {"country_name": "ALGERIA", "iso": "DZ"}, + ] + + +async def test_or_with_and(): + res = ( + await rest_client() + .from_("countries") + .select("country_name, iso") + .or_("phonecode.gt.506,and(iso.eq.AL,nicename.eq.Albania)") + .execute() + ) + + assert res.data == [ + {"country_name": "ALBANIA", "iso": "AL"}, + {"country_name": "TRINIDAD AND TOBAGO", "iso": "TT"}, + ] + + +async def test_or_in(): + res = ( + await rest_client() + .from_("issues") + .select("id, title") + .or_("id.in.(1,4),tags.cs.{is:open,priority:high}") + .execute() + ) + + assert res.data == [ + {"id": 1, "title": "Cache invalidation is not working"}, + {"id": 3, "title": "Add missing postgrest filters"}, + {"id": 4, "title": "Add alias to filters"}, + ] + + +async def test_or_on_reference_table(): + res = ( + await rest_client() + .from_("countries") + .select("country_name, cities!inner(name)") + .or_("country_id.eq.10,name.eq.Paris", reference_table="cities") + .execute() + ) + + assert res.data == [ + { + "country_name": "UNITED KINGDOM", + "cities": [ + {"name": "London"}, + {"name": "Manchester"}, + {"name": "Liverpool"}, + {"name": "Bristol"}, + ], + }, + ] diff --git a/tests/_sync/client.py b/tests/_sync/client.py new file mode 100644 index 00000000..7b3f3e09 --- /dev/null +++ b/tests/_sync/client.py @@ -0,0 +1,9 @@ +from postgrest import SyncPostgrestClient + +REST_URL = "http://127.0.0.1:3000" + + +def rest_client(): + return SyncPostgrestClient( + base_url=REST_URL, + ) diff --git a/tests/_sync/test_filter_request_builder.py b/tests/_sync/test_filter_request_builder.py index a5454b97..c43da95b 100644 --- a/tests/_sync/test_filter_request_builder.py +++ b/tests/_sync/test_filter_request_builder.py @@ -200,3 +200,18 @@ def test_in_(filter_request_builder): builder = filter_request_builder.in_("x", ["a", "b"]) assert str(builder.params) == "x=in.%28a%2Cb%29" + + +def test_or_(filter_request_builder): + builder = filter_request_builder.or_("x.eq.1") + + assert str(builder.params) == "or=%28x.eq.1%29" + + +def test_or_in_contain(filter_request_builder): + builder = filter_request_builder.or_("id.in.(5,6,7), arraycol.cs.{'a','b'}") + + assert ( + str(builder.params) + == "or=%28id.in.%285%2C6%2C7%29%2C%20arraycol.cs.%7B%27a%27%2C%27b%27%7D%29" + ) diff --git a/tests/_sync/test_filter_request_builder_integration.py b/tests/_sync/test_filter_request_builder_integration.py new file mode 100644 index 00000000..e16178d3 --- /dev/null +++ b/tests/_sync/test_filter_request_builder_integration.py @@ -0,0 +1,328 @@ +from .client import rest_client + + +def test_multivalued_param(): + res = ( + rest_client() + .from_("countries") + .select("country_name, iso", count="exact") + .lte("numcode", 8) + .gte("numcode", 4) + .execute() + ) + + assert res.count == 2 + assert res.data == [ + {"country_name": "AFGHANISTAN", "iso": "AF"}, + {"country_name": "ALBANIA", "iso": "AL"}, + ] + + +def test_match(): + res = ( + rest_client() + .from_("countries") + .select("country_name, iso") + .match({"numcode": 8, "nicename": "Albania"}) + .single() + .execute() + ) + + assert res.data == {"country_name": "ALBANIA", "iso": "AL"} + + +def test_equals(): + res = ( + rest_client() + .from_("countries") + .select("country_name, iso") + .eq("nicename", "Albania") + .single() + .execute() + ) + + assert res.data == {"country_name": "ALBANIA", "iso": "AL"} + + +def test_not_equal(): + res = ( + rest_client() + .from_("users") + .select("id, name") + .neq("name", "Jane") + .single() + .execute() + ) + + assert res.data == {"id": 1, "name": "Michael"} + + +def test_greater_than(): + res = rest_client().from_("users").select("id, name").gt("id", 1).single().execute() + + assert res.data == {"id": 2, "name": "Jane"} + + +def test_greater_than_or_equals_to(): + res = rest_client().from_("users").select("id, name").gte("id", 1).execute() + + assert res.data == [{"id": 1, "name": "Michael"}, {"id": 2, "name": "Jane"}] + + +def test_contains_dictionary(): + res = ( + rest_client() + .from_("users") + .select("name") + .contains("address", {"postcode": 90210}) + .single() + .execute() + ) + + assert res.data == {"name": "Michael"} + + +def test_contains_any_item(): + res = ( + rest_client() + .from_("issues") + .select("title") + .contains("tags", ["is:open", "priority:low"]) + .execute() + ) + + assert res.data == [{"title": "Cache invalidation is not working"}] + + +def test_contains_on_range(): + res = ( + rest_client() + .from_("reservations") + .select("id, room_name") + .contains("during", "[2000-01-01 13:00, 2000-01-01 13:30)") + .execute() + ) + + assert res.data == [{"id": 1, "room_name": "Emerald"}] + + +def test_contained_by_mixed_items(): + res = ( + rest_client() + .from_("reservations") + .select("id, room_name") + .contained_by("during", "[2000-01-01 00:00, 2000-01-01 23:59)") + .execute() + ) + + assert res.data == [{"id": 1, "room_name": "Emerald"}] + + +def test_range_greater_than(): + res = ( + rest_client() + .from_("reservations") + .select("id, room_name") + .range_gt("during", ["2000-01-02 08:00", "2000-01-02 09:00"]) + .execute() + ) + + assert res.data == [{"id": 2, "room_name": "Topaz"}] + + +def test_range_greater_than_or_equal_to(): + res = ( + rest_client() + .from_("reservations") + .select("id, room_name") + .range_gte("during", ["2000-01-02 08:30", "2000-01-02 09:30"]) + .execute() + ) + + assert res.data == [{"id": 2, "room_name": "Topaz"}] + + +def test_range_less_than(): + res = ( + rest_client() + .from_("reservations") + .select("id, room_name") + .range_lt("during", ["2000-01-01 15:00", "2000-01-02 16:00"]) + .execute() + ) + + assert res.data == [{"id": 1, "room_name": "Emerald"}] + + +def test_range_less_than_or_equal_to(): + res = ( + rest_client() + .from_("reservations") + .select("id, room_name") + .range_lte("during", ["2000-01-01 14:00", "2000-01-01 16:00"]) + .execute() + ) + + assert res.data == [{"id": 1, "room_name": "Emerald"}] + + +def test_range_adjacent(): + res = ( + rest_client() + .from_("reservations") + .select("id, room_name") + .range_adjacent("during", ["2000-01-01 12:00", "2000-01-01 13:00"]) + .execute() + ) + + assert res.data == [{"id": 1, "room_name": "Emerald"}] + + +def test_overlaps(): + res = ( + rest_client() + .from_("issues") + .select("title") + .overlaps("tags", ["is:closed", "severity:high"]) + .execute() + ) + + assert res.data == [ + {"title": "Cache invalidation is not working"}, + {"title": "Add alias to filters"}, + ] + + +def test_like(): + res = ( + rest_client() + .from_("countries") + .select("country_name, iso") + .like("nicename", "%Alba%") + .execute() + ) + + assert res.data == [{"country_name": "ALBANIA", "iso": "AL"}] + + +def test_ilike(): + res = ( + rest_client() + .from_("countries") + .select("country_name, iso") + .ilike("nicename", "%alban%") + .execute() + ) + + assert res.data == [{"country_name": "ALBANIA", "iso": "AL"}] + + +def test_is_(): + res = ( + rest_client() + .from_("countries") + .select("country_name, iso") + .is_("numcode", "null") + .limit(1) + .order("nicename") + .execute() + ) + + assert res.data == [{"country_name": "ANTARCTICA", "iso": "AQ"}] + + +def test_is_not(): + res = ( + rest_client() + .from_("countries") + .select("country_name, iso") + .not_.is_("numcode", "null") + .limit(1) + .order("nicename") + .execute() + ) + + assert res.data == [{"country_name": "AFGHANISTAN", "iso": "AF"}] + + +def test_in_(): + res = ( + rest_client() + .from_("countries") + .select("country_name, iso") + .in_("nicename", ["Albania", "Algeria"]) + .execute() + ) + + assert res.data == [ + {"country_name": "ALBANIA", "iso": "AL"}, + {"country_name": "ALGERIA", "iso": "DZ"}, + ] + + +def test_or_(): + res = ( + rest_client() + .from_("countries") + .select("country_name, iso") + .or_("iso.eq.DZ,nicename.eq.Albania") + .execute() + ) + + assert res.data == [ + {"country_name": "ALBANIA", "iso": "AL"}, + {"country_name": "ALGERIA", "iso": "DZ"}, + ] + + +def test_or_with_and(): + res = ( + rest_client() + .from_("countries") + .select("country_name, iso") + .or_("phonecode.gt.506,and(iso.eq.AL,nicename.eq.Albania)") + .execute() + ) + + assert res.data == [ + {"country_name": "ALBANIA", "iso": "AL"}, + {"country_name": "TRINIDAD AND TOBAGO", "iso": "TT"}, + ] + + +def test_or_in(): + res = ( + rest_client() + .from_("issues") + .select("id, title") + .or_("id.in.(1,4),tags.cs.{is:open,priority:high}") + .execute() + ) + + assert res.data == [ + {"id": 1, "title": "Cache invalidation is not working"}, + {"id": 3, "title": "Add missing postgrest filters"}, + {"id": 4, "title": "Add alias to filters"}, + ] + + +def test_or_on_reference_table(): + res = ( + rest_client() + .from_("countries") + .select("country_name, cities!inner(name)") + .or_("country_id.eq.10,name.eq.Paris", reference_table="cities") + .execute() + ) + + assert res.data == [ + { + "country_name": "UNITED KINGDOM", + "cities": [ + {"name": "London"}, + {"name": "Manchester"}, + {"name": "Liverpool"}, + {"name": "Bristol"}, + ], + }, + ]