ibm-db-haystack 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/CHANGELOG.md +15 -0
- {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/PKG-INFO +5 -2
- {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/README.md +3 -0
- {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/src/haystack_integrations/document_stores/ibm_db/document_store.py +95 -36
- {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/tests/test_document_store.py +136 -0
- {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/.gitignore +0 -0
- {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/LICENSE.txt +0 -0
- {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/docker-compose.yml +0 -0
- {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/pydoc/config_docusaurus.yml +0 -0
- {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/pyproject.toml +0 -0
- {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/src/haystack_integrations/components/retrievers/ibm_db/__init__.py +0 -0
- {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/src/haystack_integrations/components/retrievers/ibm_db/embedding_retriever.py +0 -0
- {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/src/haystack_integrations/components/retrievers/py.typed +0 -0
- {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/src/haystack_integrations/document_stores/ibm_db/__init__.py +0 -0
- {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/src/haystack_integrations/document_stores/ibm_db/filters.py +0 -0
- {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/src/haystack_integrations/document_stores/py.typed +0 -0
- {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/tests/__init__.py +0 -0
- {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/tests/conftest.py +0 -0
- {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/tests/test_embedding_retriever.py +0 -0
- {ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/tests/test_filters.py +0 -0
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [integrations/ibm_db-v0.2.0] - 2026-07-27
|
|
4
|
+
|
|
5
|
+
### 🚀 Features
|
|
6
|
+
|
|
7
|
+
- IBM Db2 - lazy connection + closing methods (#3664)
|
|
8
|
+
|
|
9
|
+
### 🐛 Bug Fixes
|
|
10
|
+
|
|
11
|
+
- Fix new issues raised by ruff 0.16.0 (#3670)
|
|
12
|
+
|
|
13
|
+
### 📚 Documentation
|
|
14
|
+
|
|
15
|
+
- Update DB2 to Db2 in README (#3575)
|
|
16
|
+
|
|
17
|
+
|
|
3
18
|
## [integrations/ibm_db-v0.1.1] - 2026-07-10
|
|
4
19
|
|
|
5
20
|
### 📚 Documentation
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: ibm-db-haystack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Haystack integration for IBM Db2
|
|
5
5
|
Project-URL: Documentation, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/ibm_db#readme
|
|
6
6
|
Project-URL: Issues, https://github.com/deepset-ai/haystack-core-integrations/issues
|
|
@@ -28,10 +28,13 @@ Description-Content-Type: text/markdown
|
|
|
28
28
|
[](https://pypi.org/project/ibm-db-haystack)
|
|
29
29
|
[](https://pypi.org/project/ibm-db-haystack)
|
|
30
30
|
|
|
31
|
+
- [Integration page](https://haystack.deepset.ai/integrations/ibm-db-document-store)
|
|
31
32
|
- [Changelog](https://github.com/deepset-ai/haystack-core-integrations/blob/main/integrations/ibm_db/CHANGELOG.md)
|
|
32
33
|
|
|
33
34
|
---
|
|
34
35
|
|
|
36
|
+
## Contributing
|
|
37
|
+
|
|
35
38
|
Refer to the general [Contribution Guidelines](https://github.com/deepset-ai/haystack-core-integrations/blob/main/CONTRIBUTING.md).
|
|
36
39
|
|
|
37
40
|
To run integration tests locally, you need to have an IBM Db2 instance running.
|
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
[](https://pypi.org/project/ibm-db-haystack)
|
|
4
4
|
[](https://pypi.org/project/ibm-db-haystack)
|
|
5
5
|
|
|
6
|
+
- [Integration page](https://haystack.deepset.ai/integrations/ibm-db-document-store)
|
|
6
7
|
- [Changelog](https://github.com/deepset-ai/haystack-core-integrations/blob/main/integrations/ibm_db/CHANGELOG.md)
|
|
7
8
|
|
|
8
9
|
---
|
|
9
10
|
|
|
11
|
+
## Contributing
|
|
12
|
+
|
|
10
13
|
Refer to the general [Contribution Guidelines](https://github.com/deepset-ai/haystack-core-integrations/blob/main/CONTRIBUTING.md).
|
|
11
14
|
|
|
12
15
|
To run integration tests locally, you need to have an IBM Db2 instance running.
|
|
@@ -579,50 +579,109 @@ class IBMDb2DocumentStore:
|
|
|
579
579
|
|
|
580
580
|
return updated_count
|
|
581
581
|
|
|
582
|
-
|
|
582
|
+
@staticmethod
|
|
583
|
+
def _normalize_metadata_field_name(field_name: str) -> str:
|
|
583
584
|
"""
|
|
584
|
-
|
|
585
|
+
Normalize a metadata field name and validate its characters.
|
|
585
586
|
|
|
586
|
-
|
|
587
|
-
|
|
587
|
+
Removes the 'meta.' prefix, if present, and validates the remaining characters to prevent
|
|
588
|
+
SQL/JSONPath injection when the name is embedded in a SQL string literal.
|
|
589
|
+
|
|
590
|
+
:param field_name: The field name to normalize.
|
|
591
|
+
:return: The normalized field name.
|
|
592
|
+
:raises ValueError: If the field name contains characters other than alphanumerics, underscores,
|
|
593
|
+
or hyphens.
|
|
588
594
|
"""
|
|
589
|
-
|
|
590
|
-
field_name = field.removeprefix("meta.")
|
|
595
|
+
field_name = field_name.removeprefix("meta.")
|
|
591
596
|
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
597
|
+
if not all(c.isalnum() or c in ("_", "-") for c in field_name):
|
|
598
|
+
msg = (
|
|
599
|
+
f"Invalid metadata field name: '{field_name}'. Field names can only contain alphanumeric "
|
|
600
|
+
f"characters, underscores, and hyphens."
|
|
601
|
+
)
|
|
602
|
+
raise ValueError(msg)
|
|
603
|
+
|
|
604
|
+
return field_name
|
|
605
|
+
|
|
606
|
+
def get_metadata_field_unique_values(
|
|
607
|
+
self,
|
|
608
|
+
metadata_field: str,
|
|
609
|
+
search_term: str | None = None,
|
|
610
|
+
from_: int = 0,
|
|
611
|
+
size: int = 10,
|
|
612
|
+
filters: dict[str, Any] | None = None,
|
|
613
|
+
) -> tuple[list[Any], int]:
|
|
614
|
+
"""
|
|
615
|
+
Get unique values for a given metadata field, optionally filtered by a search term.
|
|
616
|
+
|
|
617
|
+
**Note**: values of different types are kept distinct even when they compare equal in Python
|
|
618
|
+
or share a textual form (e.g. the int `1`, the bool `True` and the str `"1"` are returned as
|
|
619
|
+
three separate values, and a whole-number float like `1.0` stays distinct from the int `1`).
|
|
620
|
+
|
|
621
|
+
:param metadata_field: The metadata field name (can include or omit the 'meta.' prefix).
|
|
622
|
+
:param search_term: Optional term to filter the returned values by, matching as a case-insensitive
|
|
623
|
+
substring of the metadata field's own value (not the document content). If None, all values
|
|
624
|
+
are considered.
|
|
625
|
+
:param from_: The offset for pagination (0-based).
|
|
626
|
+
:param size: The number of unique values to return.
|
|
627
|
+
:param filters: Optional filters to restrict the documents considered.
|
|
628
|
+
:return: A tuple containing (list of unique values in their original JSON type, total count of
|
|
629
|
+
unique values matching `search_term`).
|
|
630
|
+
"""
|
|
631
|
+
field_name = self._normalize_metadata_field_name(metadata_field)
|
|
632
|
+
|
|
633
|
+
# Db2 raises SQL0134N ("a LOB column can't be used with DISTINCT/GROUP BY") when DISTINCT is
|
|
634
|
+
# applied directly to a JSON_QUERY(...) expression derived from the BLOB `meta` column. Wrapping
|
|
635
|
+
# the extraction in a derived table materializes it as a plain VARCHAR column first, so DISTINCT
|
|
636
|
+
# and ORDER BY in the outer query operate on that column instead of the LOB-derived expression.
|
|
637
|
+
# `filters` is applied inside this subquery (not the outer one) since it references the raw
|
|
638
|
+
# `meta` column, which only exists at this level — the outer query only sees the extracted `value`.
|
|
639
|
+
#
|
|
640
|
+
# JSON_VALUE dequotes strings and stringifies numbers/booleans, so a JSON string "1" and the
|
|
641
|
+
# number 1 both become the identical SQL text '1' - merging distinct values and, once re-parsed,
|
|
642
|
+
# corrupting a numeric-looking string into a number. JSON_QUERY preserves the scalar's literal
|
|
643
|
+
# JSON form instead (quotes for strings, `true`/`false` for booleans), so json.loads() below can
|
|
644
|
+
# recover the original type, and DISTINCT compares literal text instead of collapsing values.
|
|
645
|
+
params: list[Any] = []
|
|
646
|
+
filter_where = ""
|
|
647
|
+
if filters:
|
|
648
|
+
filter_expression = FilterTranslator().translate(filters, params)
|
|
649
|
+
filter_where = f" WHERE {filter_expression}"
|
|
650
|
+
|
|
651
|
+
value_subquery = (
|
|
652
|
+
f"SELECT JSON_QUERY(SYSTOOLS.BSON2JSON(meta), '$.{field_name}' RETURNING VARCHAR(1000)) AS value "
|
|
653
|
+
f"FROM {self.table_name}{filter_where}"
|
|
600
654
|
)
|
|
601
|
-
|
|
602
|
-
|
|
655
|
+
|
|
656
|
+
search_clause = ""
|
|
657
|
+
if search_term is not None:
|
|
658
|
+
search_clause = " AND LOCATE(UPPER(?), UPPER(value)) > 0"
|
|
659
|
+
params.append(search_term)
|
|
660
|
+
|
|
661
|
+
count_sql = f"SELECT COUNT(DISTINCT value) FROM ({value_subquery}) AS t WHERE value IS NOT NULL{search_clause}"
|
|
662
|
+
select_sql = (
|
|
663
|
+
f"SELECT DISTINCT value FROM ({value_subquery}) AS t WHERE value IS NOT NULL{search_clause} "
|
|
664
|
+
f"ORDER BY value OFFSET ? ROWS FETCH FIRST ? ROWS ONLY"
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
with self._transaction(f"Failed to get unique values for field '{metadata_field}'") as cur:
|
|
668
|
+
cur.execute(count_sql, params)
|
|
669
|
+
count_row = cur.fetchone()
|
|
670
|
+
total_count = count_row[0] if count_row and count_row[0] is not None else 0
|
|
671
|
+
|
|
672
|
+
cur.execute(select_sql, [*params, from_, size])
|
|
603
673
|
rows = cur.fetchall()
|
|
604
674
|
|
|
605
|
-
|
|
606
|
-
seen = set()
|
|
607
|
-
values = []
|
|
675
|
+
unique_values: list[Any] = []
|
|
608
676
|
for row in rows:
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
seen.add(value_key)
|
|
618
|
-
values.append(parsed_value)
|
|
619
|
-
except (json.JSONDecodeError, TypeError):
|
|
620
|
-
# If it's not valid JSON, use the string value
|
|
621
|
-
if value not in seen:
|
|
622
|
-
seen.add(value)
|
|
623
|
-
values.append(value)
|
|
624
|
-
|
|
625
|
-
return values
|
|
677
|
+
if row[0] is None:
|
|
678
|
+
continue
|
|
679
|
+
try:
|
|
680
|
+
unique_values.append(json.loads(row[0]))
|
|
681
|
+
except (json.JSONDecodeError, TypeError):
|
|
682
|
+
unique_values.append(row[0])
|
|
683
|
+
|
|
684
|
+
return unique_values, total_count
|
|
626
685
|
|
|
627
686
|
def get_metadata_field_min_max(self, field: str) -> dict[str, Any]:
|
|
628
687
|
"""
|
|
@@ -239,6 +239,62 @@ class TestDocumentStore(
|
|
|
239
239
|
assert [doc.id for doc in results] == ["a", "c"]
|
|
240
240
|
assert all(doc.score is not None for doc in results)
|
|
241
241
|
|
|
242
|
+
def test_get_metadata_field_unique_values_pagination(self, document_store: IBMDb2DocumentStore):
|
|
243
|
+
docs = [
|
|
244
|
+
Document(content=f"doc {i}", meta={"category": c}) for i, c in enumerate(["A", "B", "A", "C", "B", "A"])
|
|
245
|
+
]
|
|
246
|
+
document_store.write_documents(docs)
|
|
247
|
+
|
|
248
|
+
page1, total1 = document_store.get_metadata_field_unique_values("category", from_=0, size=2)
|
|
249
|
+
page2, total2 = document_store.get_metadata_field_unique_values("category", from_=2, size=2)
|
|
250
|
+
|
|
251
|
+
assert len(page1) == 2
|
|
252
|
+
assert len(page2) == 1
|
|
253
|
+
assert total1 == total2 == 3
|
|
254
|
+
assert not set(page1).intersection(page2)
|
|
255
|
+
assert set(page1) | set(page2) == {"A", "B", "C"}
|
|
256
|
+
|
|
257
|
+
all_values, total = document_store.get_metadata_field_unique_values("category", from_=0, size=100)
|
|
258
|
+
assert set(all_values) == {"A", "B", "C"}
|
|
259
|
+
assert total == 3
|
|
260
|
+
|
|
261
|
+
beyond, total_beyond = document_store.get_metadata_field_unique_values("category", from_=10, size=10)
|
|
262
|
+
assert beyond == []
|
|
263
|
+
assert total_beyond == 3
|
|
264
|
+
|
|
265
|
+
def test_get_metadata_field_unique_values_invalid_field_name(self, document_store: IBMDb2DocumentStore):
|
|
266
|
+
with pytest.raises(ValueError, match="Invalid metadata field name"):
|
|
267
|
+
document_store.get_metadata_field_unique_values("field' OR '1'='1")
|
|
268
|
+
|
|
269
|
+
def test_get_metadata_field_unique_values_hyphenated_field_name(self, document_store: IBMDb2DocumentStore):
|
|
270
|
+
docs = [
|
|
271
|
+
Document(content="one", meta={"some-field": "A"}),
|
|
272
|
+
Document(content="two", meta={"some-field": "B"}),
|
|
273
|
+
Document(content="three", meta={"some-field": "A"}),
|
|
274
|
+
]
|
|
275
|
+
document_store.write_documents(docs)
|
|
276
|
+
|
|
277
|
+
values, total_count = document_store.get_metadata_field_unique_values("some-field")
|
|
278
|
+
assert set(values) == {"A", "B"}
|
|
279
|
+
assert total_count == 2
|
|
280
|
+
|
|
281
|
+
def test_case_insensitive_and_special_characters_are_allowed(self, document_store: IBMDb2DocumentStore):
|
|
282
|
+
docs = [
|
|
283
|
+
Document(meta={"category": "100% complete"}),
|
|
284
|
+
Document(meta={"category": "plain"}),
|
|
285
|
+
Document(meta={"category": "under_score"}),
|
|
286
|
+
Document(meta={"category": "underXscore"}),
|
|
287
|
+
]
|
|
288
|
+
document_store.write_documents(docs)
|
|
289
|
+
|
|
290
|
+
values, total = document_store.get_metadata_field_unique_values("category", search_term="%")
|
|
291
|
+
assert values == ["100% complete"]
|
|
292
|
+
assert total == 1
|
|
293
|
+
|
|
294
|
+
values, total = document_store.get_metadata_field_unique_values("category", search_term="_")
|
|
295
|
+
assert values == ["under_score"]
|
|
296
|
+
assert total == 1
|
|
297
|
+
|
|
242
298
|
|
|
243
299
|
class TestIBMDb2DocumentStoreUtilMethods:
|
|
244
300
|
"""Unit tests for pure utility methods: _parse_embedding, _validate_embedding, _infer_field_type."""
|
|
@@ -300,6 +356,32 @@ class TestIBMDb2DocumentStoreUtilMethods:
|
|
|
300
356
|
def test_infer_field_type(self, value, expected):
|
|
301
357
|
assert IBMDb2DocumentStore._infer_field_type(value) == expected
|
|
302
358
|
|
|
359
|
+
# _normalize_metadata_field_name
|
|
360
|
+
def test_normalize_metadata_field_name_strips_meta_prefix(self):
|
|
361
|
+
assert IBMDb2DocumentStore._normalize_metadata_field_name("meta.category") == "category"
|
|
362
|
+
|
|
363
|
+
def test_normalize_metadata_field_name_without_prefix_unchanged(self):
|
|
364
|
+
assert IBMDb2DocumentStore._normalize_metadata_field_name("category") == "category"
|
|
365
|
+
|
|
366
|
+
def test_normalize_metadata_field_name_allows_underscore_and_hyphen(self):
|
|
367
|
+
assert IBMDb2DocumentStore._normalize_metadata_field_name("meta.some_field-name") == "some_field-name"
|
|
368
|
+
|
|
369
|
+
@pytest.mark.parametrize(
|
|
370
|
+
"bad_field",
|
|
371
|
+
[
|
|
372
|
+
"field@invalid",
|
|
373
|
+
"field with spaces",
|
|
374
|
+
"field;drop",
|
|
375
|
+
"meta.field!",
|
|
376
|
+
"field' OR '1'='1",
|
|
377
|
+
"../etc/passwd",
|
|
378
|
+
"field/* comment */",
|
|
379
|
+
],
|
|
380
|
+
)
|
|
381
|
+
def test_normalize_metadata_field_name_rejects_invalid_chars(self, bad_field):
|
|
382
|
+
with pytest.raises(ValueError, match="Invalid metadata field name"):
|
|
383
|
+
IBMDb2DocumentStore._normalize_metadata_field_name(bad_field)
|
|
384
|
+
|
|
303
385
|
|
|
304
386
|
@pytest.fixture
|
|
305
387
|
def unit_store(monkeypatch) -> IBMDb2DocumentStore:
|
|
@@ -575,3 +657,57 @@ class TestIBMDb2DocumentStoreUnit:
|
|
|
575
657
|
|
|
576
658
|
with pytest.raises(RuntimeError, match="connection reset"):
|
|
577
659
|
store._embedding_retrieval([0.1, 0.2], top_k=1)
|
|
660
|
+
|
|
661
|
+
def test_get_metadata_field_unique_values_builds_paginated_sql(self, mocked_store):
|
|
662
|
+
store, _, cur = mocked_store
|
|
663
|
+
cur.fetchone.return_value = (2,)
|
|
664
|
+
# JSON_QUERY returns the literal JSON form of a string - quoted - unlike JSON_VALUE which
|
|
665
|
+
# dequotes it; json.loads() below relies on that to tell a string apart from a number.
|
|
666
|
+
cur.fetchall.return_value = [('"A"',), ('"B"',)]
|
|
667
|
+
|
|
668
|
+
values, total_count = store.get_metadata_field_unique_values("meta.category", from_=5, size=10)
|
|
669
|
+
|
|
670
|
+
assert values == ["A", "B"]
|
|
671
|
+
assert total_count == 2
|
|
672
|
+
|
|
673
|
+
count_sql, count_params = cur.execute.call_args_list[0].args
|
|
674
|
+
select_sql, select_params = cur.execute.call_args_list[1].args
|
|
675
|
+
|
|
676
|
+
assert "COUNT(DISTINCT value)" in count_sql
|
|
677
|
+
assert "$.category" in count_sql
|
|
678
|
+
assert count_params == []
|
|
679
|
+
|
|
680
|
+
assert "SELECT DISTINCT value" in select_sql
|
|
681
|
+
assert "ORDER BY value OFFSET ? ROWS FETCH FIRST ? ROWS ONLY" in select_sql
|
|
682
|
+
assert select_params == [5, 10]
|
|
683
|
+
|
|
684
|
+
def test_get_metadata_field_unique_values_applies_search_term(self, mocked_store):
|
|
685
|
+
store, _, cur = mocked_store
|
|
686
|
+
cur.fetchone.return_value = (1,)
|
|
687
|
+
cur.fetchall.return_value = [('"Python-based"',)]
|
|
688
|
+
|
|
689
|
+
store.get_metadata_field_unique_values("meta.category", search_term="python")
|
|
690
|
+
|
|
691
|
+
count_sql, count_params = cur.execute.call_args_list[0].args
|
|
692
|
+
select_sql, select_params = cur.execute.call_args_list[1].args
|
|
693
|
+
|
|
694
|
+
assert "LOCATE(UPPER(?), UPPER(value)) > 0" in count_sql
|
|
695
|
+
assert count_params == ["python"]
|
|
696
|
+
assert "LOCATE(UPPER(?), UPPER(value)) > 0" in select_sql
|
|
697
|
+
assert select_params == ["python", 0, 10]
|
|
698
|
+
|
|
699
|
+
def test_get_metadata_field_unique_values_rejects_invalid_field_name(self, mocked_store):
|
|
700
|
+
store, _, _ = mocked_store
|
|
701
|
+
|
|
702
|
+
with pytest.raises(ValueError, match="Invalid metadata field name"):
|
|
703
|
+
store.get_metadata_field_unique_values("field' OR '1'='1")
|
|
704
|
+
|
|
705
|
+
def test_get_metadata_field_unique_values_handles_no_rows(self, mocked_store):
|
|
706
|
+
store, _, cur = mocked_store
|
|
707
|
+
cur.fetchone.return_value = None
|
|
708
|
+
cur.fetchall.return_value = []
|
|
709
|
+
|
|
710
|
+
values, total_count = store.get_metadata_field_unique_values("meta.category")
|
|
711
|
+
|
|
712
|
+
assert values == []
|
|
713
|
+
assert total_count == 0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ibm_db_haystack-0.2.0 → ibm_db_haystack-0.3.0}/src/haystack_integrations/document_stores/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|