diff --git a/src/arcadedb/type_conversion.py b/src/arcadedb/type_conversion.py index 557100f..10c310a 100644 --- a/src/arcadedb/type_conversion.py +++ b/src/arcadedb/type_conversion.py @@ -795,13 +795,19 @@ def _dumps_checked(rows: Any, **kwargs: Any) -> str: BigDecimal, so it was stored as a DECIMAL and read back as ``Decimal`` where every other write path stores a double. ValueError sends the row down the per-row path, which stores the double. Every subnormal is written with - an exponent of -308 or below, so the text is searched first (C speed) for - such an exponent after a digit, and only a hit pays for the walk over the - rows. A bare "e-3" would also hit ordinary text (hex ids, UUIDs, "name-3") - and send every such load through the walk. + an exponent of -308 or below, so the text is searched for such an exponent + after a digit, and only a hit pays for the walk over the rows. The search has + two steps: the plain substring "e-3" (a memchr-speed scan, about a tenth of + what the expression costs on a text of digits) must be there before the + expression is run. Ordinary text ("name-3", hex ids, UUIDs) passes the first + step and is turned back by the second, so it never reaches the walk. """ text = json.dumps(rows, allow_nan=False, **kwargs) - if _SUBNORMAL_EXPONENT.search(text) and any(_has_subnormal(row) for row in rows): + if ( + "e-3" in text + and _SUBNORMAL_EXPONENT.search(text) + and any(_has_subnormal(row) for row in rows) + ): raise ValueError("a subnormal double cannot go through the JSON bulk path") return text @@ -867,23 +873,25 @@ def _kind_is_tagged(kind: type) -> bool: ) -def _rows_need_walk(rows: Any, tagged: bool) -> bool: - """Whether the rows may hold a value the JSON text would change, decided from their types and their columns, in C. - - Only exact dict rows with str keys can be decided here. Each column is one list of its values and one census of their types; - a column of the exact scalar types is screened (`_scalar_column_suspect`: the 64-bit range of its ints, the UTF-8 form of its - strs), a column of the kinds bulk_tag_default writes (tagged) holds nothing the walk has to see, and any other type, or a - column of mixed kinds, sends the rows to the walk. A false alarm costs the walk and changes nothing else. This replaces two - scans of the JSON text with regular expressions (a lone surrogate, an integer token of 2**63 and above): the one for the - integer token cost 2.5 microseconds a row of a three-column table (12 ms of a 5,000-row call), a column screen about 0.2. +def _screen_step(rows: Any) -> int: + """Rows per block of the column screens: about _SCREEN_CELLS values, so one block's rows, column lists and joined texts stay in the CPU + cache across the passes made over them. Every pass over the values of an input that does not fit the cache reads them from memory + again (the screens of 26.11.1.post2 read a 250,000-row table five times over); a block is read from memory once. """ - if not set(map(type, rows)) <= _DICT_ONLY: + first = next(filter(None, rows[:64]), None) + width = len(first) if type(first) is dict else 8 + return max(_SCREEN_MIN_ROWS, _SCREEN_CELLS // max(width, 1)) + + +def _block_needs_walk(block: Any, tagged: bool) -> bool: + """`_rows_need_walk` for one block of rows.""" + if not set(map(type, block)) <= _DICT_ONLY: return True - keys = set(itertools.chain.from_iterable(rows)) + keys = set(itertools.chain.from_iterable(block)) if not set(map(type, keys)) <= _STR_ONLY: return True for key in keys: - column = list(map(dict.get, rows, itertools.repeat(key))) + column = list(map(dict.get, block, itertools.repeat(key))) kinds = set(map(type, column)) extra = kinds - _BULK_SCALAR_TYPES if extra: @@ -897,25 +905,53 @@ def _rows_need_walk(rows: Any, tagged: bool) -> bool: return False +def _rows_need_walk(rows: Any, tagged: bool) -> bool: + """Whether the rows may hold a value the JSON text would change, decided from their types and their columns, in C. + + Only exact dict rows with str keys can be decided here. Each column of a block of rows (`_screen_step`) is one list of its values + and one census of their types; a column of the exact scalar types is screened (`_scalar_column_suspect`: the 64-bit range of its + ints, the UTF-8 form of its strs; a float column is left to json.dumps and the text, see `_dumps_checked`), a column of the kinds + bulk_tag_default writes (tagged) holds nothing the walk has to see, and any other type, or a column of mixed kinds, sends the rows + to the walk. A false alarm costs the walk and changes nothing else. This replaces two scans of the JSON text with regular + expressions (a lone surrogate, an integer token of 2**63 and above): the one for the integer token cost 2.5 microseconds a row of + a three-column table, a column screen about 0.1. + """ + step = _screen_step(rows) + if len(rows) <= step: + return _block_needs_walk(rows, tagged) + return any( + _block_needs_walk(rows[start : start + step], tagged) + for start in range(0, len(rows), step) + ) + + def _dumps_once(rows: Any, tagged: bool, vetted: bool = False) -> str: """One json.dumps pass for the bulk paths, with the checks of `_check_rows` decided from the types and columns of the rows. A str or int the text would change cannot be seen by a json.dumps hook (the encoder writes both itself), so the rows decide: `_rows_need_walk` screens their columns in C and sends the rows to the walk when a value may be bad or its type is one it cannot - vouch for. When json.dumps itself refuses the rows (NaN, an unknown type, a subnormal, a hook error) the walk runs first, as the - validation loop used to run before json.dumps, so the error of the first bad row in row order is still the walk's; if the walk - finds nothing, json.dumps's own error stands. A false alarm costs the walk and changes nothing else. + vouch for, and the walk then runs before the text is written, as the validation loop used to run before json.dumps, so the error of + the first bad row in row order is still the walk's. When json.dumps itself refuses rows that did not need the walk (NaN, an unknown + type, a subnormal, a hook error) the walk runs after it; if the walk finds nothing, json.dumps's own error stands. A false alarm + costs the walk and changes nothing else. + + Rows that did not need the walk hold no list, tuple or dict, so no value can contain itself: their text is written without the + encoder's cycle bookkeeping (``check_circular=False``, a dict insert and delete for every row). Rows the walk looked at keep it. `vetted` is for a caller that has already screened these rows with the same census (`screen_vertex_rows`: every value an exact scalar, no NaN, Infinity, subnormal, integer beyond 64 bits or lone surrogate): the text is written without a second look. """ + walked = not vetted and _rows_need_walk(rows, tagged) + if walked: + _check_rows(rows, tagged) + kwargs = {"check_circular": walked} + if tagged: + kwargs["default"] = bulk_tag_default try: if vetted: - text = json.dumps(rows, allow_nan=False) - elif tagged: - text = _dumps_checked(rows, default=bulk_tag_default) + text = json.dumps(rows, allow_nan=False, **kwargs) else: - text = _dumps_checked(rows) + text = _dumps_checked(rows, **kwargs) except (TypeError, ValueError) as error: refused = error else: @@ -923,12 +959,11 @@ def _dumps_once(rows: Any, tagged: bool, vetted: bool = False) -> str: if refused is not None: # outside the except block, so the walk's error does not carry json.dumps's as __context__ (the old loop ran first) try: - _check_rows(rows, tagged) + if not walked: + _check_rows(rows, tagged) raise refused finally: del refused - if not vetted and _rows_need_walk(rows, tagged): - _check_rows(rows, tagged) return text @@ -1059,16 +1094,20 @@ def _column(rows: Any, key: str): _NONE_KIND = type(None) -_SCREEN_BLOCK = 100_000 # rows per block of the scalar screen, so its transient lists and joined texts stay bounded +# values per block of the column screens (`_screen_step`): a block of about this many values is about a megabyte of objects, and stays in +# the cache between the passes (list, census, min and max, join, sum) that the screen makes over it; at least _SCREEN_MIN_ROWS rows per block +_SCREEN_CELLS = 16_384 +_SCREEN_MIN_ROWS = 256 def _scalar_column_suspect(column: list, kinds: Any = None) -> bool: """Whether one block of a column of exact scalar values may hold a value the JSON text would change. Conservative (a false alarm only sends the column to the exact per-value judgement) and run in C: the type census, the joined - text of a str column (a lone surrogate has no UTF-8 form), min and max of an int column (the 64-bit range), and a float column as - one float64 array (NaN, Infinity, a subnormal). Columns of mixed kinds are suspect. `kinds` is the set of the types of `column` - when the caller has taken that census already (it is not changed). + text of a str column (a lone surrogate has no UTF-8 form), min and max of an int column (the 64-bit range), and for a float column + its sum (NaN and the infinities make it non-finite; an overflow is a false alarm) and its smallest non-zero absolute value (a + subnormal). Columns of mixed kinds are suspect. `kinds` is the set of the types of `column` when the caller has taken that census + already (it is not changed). """ kinds = set(map(type, column)) if kinds is None else set(kinds) if _NONE_KIND in kinds: @@ -1090,32 +1129,39 @@ def _scalar_column_suspect(column: list, kinds: Any = None) -> bool: return False if kind is int: return not (_INT64_MIN <= min(column) and max(column) <= _INT64_MAX) - if kind is float and _np is not None: - values = _np.fromiter(column, dtype=_np.float64, count=len(column)) - if not _np.isfinite(values).all(): + if kind is float: + if not math.isfinite(sum(column)): return True - values = values[values != 0.0] - return bool((_np.abs(values) < _FLOAT_NORMAL_MIN).any()) + if min(map(abs, column)) >= _FLOAT_NORMAL_MIN: + return False # no zero and no subnormal: the common column, one pass + nonzero = list(filter(None, column)) # a zero is no subnormal + return bool(nonzero) and min(map(abs, nonzero)) < _FLOAT_NORMAL_MIN return True -def _screen_key(rows: Any, key: str, screen: bool): - """(odd, suspect) for the column `key` of all `rows`: the types in it beyond the exact scalars, and, with `screen`, whether a column - of exact scalars only may hold a value the JSON text would change. One list and one census per block of `_SCREEN_BLOCK` rows. +def _screen_columns(rows: Any, screen: bool): + """(odd, suspect) over all `rows` (dicts and None), each a dict by key: the types of the column beyond the exact scalars, and, with + `screen`, whether a column of exact scalars only may hold a value the JSON text would change. None when a key is not a str. One + list and one census per key and block of rows (`_screen_step`); the block is the outer loop, so the values of a block are read from + memory once. """ - odd = set() - suspect = False - for start in range(0, len(rows), _SCREEN_BLOCK): - block = ( - rows if len(rows) <= _SCREEN_BLOCK else rows[start : start + _SCREEN_BLOCK] - ) - column = list(_column(block, key)) - kinds = set(map(type, column)) - extra = kinds - _BULK_SCALAR_TYPES - if extra: - odd |= extra - elif screen and not suspect: - suspect = _scalar_column_suspect(column, kinds) + odd = {} + suspect = {} + step = _screen_step(rows) + for start in range(0, len(rows), step): + block = rows if len(rows) <= step else rows[start : start + step] + keys = set(itertools.chain.from_iterable(filter(None, block))) + if not set(map(type, keys)) <= _STR_ONLY: + return None + for key in keys: + column = list(_column(block, key)) + kinds = set(map(type, column)) + extra = kinds - _BULK_SCALAR_TYPES + if extra: + odd.setdefault(key, set()).update(extra) + elif screen and not suspect.get(key): + suspect[key] = _scalar_column_suspect(column, kinds) + odd.setdefault(key, set()) return odd, suspect @@ -1130,13 +1176,14 @@ def _classify_rows(rows: Any, declared: Any, screen_scalars: bool): return ( None if tagged is None else (tagged, False) ) # a row that is no dict: the walk raises what it always raised - keys = set(itertools.chain.from_iterable(filter(None, rows))) - if not set(map(type, keys)) <= _STR_ONLY: + columns = _screen_columns(rows, screen_scalars) + if columns is None: return None + column_odd, column_suspect = columns tagged = False vetted = screen_scalars - for key in keys: - odd, suspect = _screen_key(rows, key, screen_scalars) + for key, odd in column_odd.items(): + suspect = column_suspect.get(key, False) if odd: vetted = False if key in declared and all(_is_vector_kind(kind) for kind in odd): @@ -1169,7 +1216,7 @@ def classify_vertex_rows(rows: Any, declared: Any, screen_scalars: bool = False) `screen_scalars` is for an input of several chunks, which are sent one after the other: a value the text would change in a late chunk would leave the earlier ones stored from the JSON text (an int as an Integer) and the late one from the matrix path (a Long), so the stored type of a property would depend on which chunk held it. With `screen_scalars` the columns of exact scalars are - screened in C over the whole input first (`_screen_key`), and a column that may hold a bad value is judged value by value, so a + screened in C over the whole input first (`_screen_columns`), and a column that may hold a bad value is judged value by value, so a bad scalar anywhere gives None for the whole input. Returns None when the rows must all go the property-matrix way (a property name that is not a str, or a value of a type the diff --git a/tests/test_bulk_single_pass_validation.py b/tests/test_bulk_single_pass_validation.py index 3d12c8b..32ab383 100644 --- a/tests/test_bulk_single_pass_validation.py +++ b/tests/test_bulk_single_pass_validation.py @@ -603,3 +603,246 @@ def test_the_scalar_screen_flags_what_the_text_would_change_and_passes_the_rest( column[249_999] = {"x": INT64_MAX + 1} assert tc.classify_vertex_rows(column, set(), True) is None assert tc.classify_vertex_rows(column, set(), False) is False + + +# --------------------------------------------------------------------------- +# #371: the step before json.dumps is no slower than the 26.11.1 loop, with numpy and without it. The screens of a float column no longer +# need numpy, the text is searched for a subnormal exponent by a substring first, the column screens run over blocks of rows, and rows with no +# list, tuple or dict are written without the encoder's cycle bookkeeping. None of it may change a text or an error. + +CLEAN_FLOAT_COLUMNS = { + "plain": [0.5, -3.25, 1 / 3, 1e5], + "zeros": [0.0, -0.0, 0.0], + "all_zero": [0.0] * 5, + "tiny_normal": [1e-300, 2.2250738585072014e-308, -2.2250738585072014e-308], + "huge": [1.7976931348623157e308, 2.5], + "huge_negative": [-1.7976931348623157e308], + "with_none": [None, 1.5, None], +} +BAD_FLOAT_COLUMNS = { + "nan": [1.5, math.nan, 2.5], + "inf": [math.inf], + "minus_inf": [1.5, -math.inf], + "smallest_subnormal": [1.5, 5e-324], + "largest_subnormal": [2.2250738585072009e-308, 1.5], + "negative_subnormal": [-5e-324, 0.0, 2.5], + "subnormal_among_zeros": [0.0, 0.0, 5e-324, 0.0], + "subnormal_only": [5e-324], +} + + +@pytest.mark.parametrize("numpy_present", [True, False], ids=["numpy", "no_numpy"]) +def test_the_float_screen_gives_the_same_verdict_with_and_without_numpy( + monkeypatch, numpy_present +): + if not numpy_present: + monkeypatch.setattr(tc, "_np", None) + for name, column in CLEAN_FLOAT_COLUMNS.items(): + assert tc._scalar_column_suspect(list(column)) is False, name + rows = [{"id": i, "x": value} for i, value in enumerate(column)] + assert tc.screen_vertex_rows(rows, set()) == (False, True), name + for name, column in BAD_FLOAT_COLUMNS.items(): + assert tc._scalar_column_suspect(list(column)) is True, name + rows = [{"id": i, "x": value} for i, value in enumerate(column)] + assert tc.screen_vertex_rows(rows, set()) is None, name + assert tc.classify_vertex_rows(rows, set(), True) is None, name + # two finite values whose sum overflows: the screen may flag them, and the judgement by value then passes them + overflow = [{"x": 1.7976931348623157e308}, {"x": 1.7976931348623157e308}] + assert tc.screen_vertex_rows(overflow, set()) == (False, True) + + +@pytest.mark.parametrize("numpy_present", [True, False], ids=["numpy", "no_numpy"]) +def test_a_clean_float_column_is_not_judged_value_by_value(monkeypatch, numpy_present): + # without numpy the old screen gave up on every float column and judged it by a function call per value + if not numpy_present: + monkeypatch.setattr(tc, "_np", None) + + def refuse(value): + raise AssertionError("a per-value gate ran over a clean float column") + + monkeypatch.setattr(tc, "json_bulk_scalar_ok", refuse) + monkeypatch.setattr(tc, "json_bulk_shape_ok", refuse) + rows = [ + {"id": i, "x": i * 0.5 + 0.25, "y": 0.0 if i % 9 == 0 else i / 3.0} + for i in range(5_000) + ] + assert tc.screen_vertex_rows(rows, set()) == (False, True) + + +def test_the_subnormal_search_runs_the_expression_only_when_the_text_has_e_minus_3( + monkeypatch, +): + calls = [] + real = tc._SUBNORMAL_EXPONENT + + class Spy: + def search(self, text): + calls.append(text[:20]) + return real.search(text) + + monkeypatch.setattr(tc, "_SUBNORMAL_EXPONENT", Spy()) + walks = [] + real_has = tc._has_subnormal + monkeypatch.setattr(tc, "_has_subnormal", lambda v: walks.append(1) or real_has(v)) + plain = _batch(200, {}) + assert tc.json_bulk_dumps(plain) == json.dumps(plain) + assert calls == [], "a text without 'e-3' is not searched by the expression" + # ordinary text with the substring reaches the expression and is turned back by it: no walk over the rows + named = [{"id": i, "name": f"node-3{i}", "w": 1.5} for i in range(50)] + assert tc.json_bulk_dumps(named) == json.dumps(named) + assert len(calls) == 1 and walks == [] + # a subnormal is still refused, with the message it always had + got = _outcome(tc.json_bulk_dumps, [{"id": 1}, {"x": 5e-324}]) + assert got == _outcome(lambda r: _old_dumps(r, False), [{"id": 1}, {"x": 5e-324}]) + assert got[0] == "ValueError" and "subnormal" in got[1] + + +@pytest.mark.parametrize("tagged", [False, True], ids=["untagged", "tagged"]) +@pytest.mark.parametrize( + "column, bad", + [ + ("id", 2**63), + ("id", INT64_MIN - 1), + ("s", "a\ud800b"), + ("x", 5e-324), + ("x", math.nan), + ("x", {1, 2}), + ("x", [2**70]), + ], + ids=["above", "below", "surrogate", "subnormal", "nan", "set", "list_big"], +) +@pytest.mark.parametrize("where", [0, 12, 13, 51, 99], ids=lambda w: f"row{w}") +def test_a_bad_value_is_found_in_any_block_of_the_column_screens( + monkeypatch, tagged, column, bad, where +): + # 40 values a block of 3 columns is 13 rows: the rows 12 and 13 are the last of one block and the first of the next + monkeypatch.setattr(tc, "_SCREEN_CELLS", 40) + monkeypatch.setattr(tc, "_SCREEN_MIN_ROWS", 4) + new = tc.json_bulk_dumps_tagged if tagged else tc.json_bulk_dumps + rows = [{"id": i, "x": i + 0.5, "s": f"n{i}"} for i in range(100)] + assert _outcome(new, rows) == ("text", json.dumps(rows)) + rows[where][column] = bad + got = _outcome(new, rows) + assert got == _outcome(lambda r: _old_dumps(r, tagged), rows) + assert got[0] in ("ValueError", "TypeError"), got + + +def test_the_column_screens_over_blocks_cover_ragged_rows_none_rows_and_late_keys( + monkeypatch, +): + monkeypatch.setattr(tc, "_SCREEN_CELLS", 40) + monkeypatch.setattr(tc, "_SCREEN_MIN_ROWS", 4) + rows = [{"id": i, "x": i * 0.25} for i in range(60)] + rows[0] = None + rows[1] = {} + assert tc.screen_vertex_rows(rows, set()) == (False, True) + # a key that first appears in the last block, a non-str key in the last block, a bad int in a column of a late key + late = rows + [{"id": 60, "late": 7}, {"id": 61, "late": 2**63}] + assert tc.screen_vertex_rows(late, set()) is None + assert tc.screen_vertex_rows(rows + [{"id": 60, 5: "x"}], set()) is None + assert tc.screen_vertex_rows(rows + [{"id": 60, "late": "a\ud800"}], set()) is None + clean_late = rows + [{"id": 60, "late": 7}, {"id": 61, "late": -7}] + assert tc.screen_vertex_rows(clean_late, set()) == (False, True) + # the same rows through json_bulk_dumps: text equal to json.dumps, or the old error + base = [row for row in rows if row] + ragged = base + [{"id": 60, "late": 7}, {"id": 61, "late": -7}] + assert tc.json_bulk_dumps(ragged) == json.dumps(ragged) + for bad in ( + base + [{"id": 60, "late": 7}, {"id": 61, "late": 2**63}], + base + [{5: "x"}], + ): + got = _outcome(tc.json_bulk_dumps, bad) + assert got == _outcome(lambda r: _old_dumps(r, False), bad) + assert got[0] == "ValueError" + + +@pytest.mark.parametrize("tagged", [False, True], ids=["untagged", "tagged"]) +def test_rows_without_a_container_are_written_without_the_cycle_check( + monkeypatch, tagged +): + seen = [] + real = json.dumps + + def spy(obj, *args, **kwargs): + seen.append(kwargs.get("check_circular", True)) + return real(obj, *args, **kwargs) + + monkeypatch.setattr(tc.json, "dumps", spy) + new = tc.json_bulk_dumps_tagged if tagged else tc.json_bulk_dumps + scalars = _batch(300, {}) + assert new(scalars) == real(scalars) + assert seen == [False] + seen.clear() + assert tc.json_bulk_dumps(scalars, vetted=True) == real(scalars) + assert seen == [False] + seen.clear() + # a list, a tuple or a dict is a container that may hold itself: the encoder keeps its check, and the walk comes first + nested = [{"id": 1, "l": [1, 2, [3]]}, {"id": 2, "d": {"k": [1]}}] + assert new(nested) == real(nested) + assert seen == [True] + + +@pytest.mark.parametrize("tagged", [False, True], ids=["untagged", "tagged"]) +def test_a_row_that_holds_itself_still_raises_what_it_raised(tagged): + cycle = [] + cycle.append(cycle) + for rows in ([{"id": 1, "l": cycle}], [{"id": 1, "d": {"again": cycle}}]): + new = tc.json_bulk_dumps_tagged if tagged else tc.json_bulk_dumps + got = _outcome(new, rows) + expected = _outcome(lambda r: _old_dumps(r, tagged), rows) + assert got[0] == expected[0] == "RecursionError" + + +def _digest(rows): + import hashlib + + return hashlib.sha256( + "\n".join(repr(row) for row in sorted(rows)).encode("utf-8") + ).hexdigest() + + +def _mixed_rows(n, subnormal_at=None): + # every kind of scalar the screens judge, in a table that spans several blocks of the column screens + rows = [] + for i in range(n): + rows.append( + { + "k": i, + "x": [0.5, 0.0, 1e-300, 1.7e308, i / 7.0][i % 5], + "n": [INT64_MAX, INT64_MIN, 0, 1_700_000_000_123_456_789, -i][i % 5], + "s": ["plain", "한글 中文", "\U0001f600 pair", "", f"user-{i}"][i % 5], + "b": i % 2 == 0, + "z": None if i % 4 == 0 else "value", + } + ) + if subnormal_at is not None: + rows[subnormal_at]["x"] = 5e-324 + return rows + + +def _stored_digest(db, type_name): + query = f"SELECT FROM {type_name}" # nosec B608 - test-controlled + got = db.query("sql", query).to_list() + return len(got), _digest( + [(r["k"], r["x"], r["n"], r["s"], r["b"], r.get("z")) for r in got] + ) + + +@pytest.mark.parametrize("subnormal_at", [None, 7_000], ids=["clean", "one_subnormal"]) +@pytest.mark.parametrize("entry", ["insert_many", "create_vertices"]) +def test_a_scalar_batch_keeps_its_count_and_digest_and_survives_a_reopen( + temp_db_path, entry, subnormal_at +): + rows = _mixed_rows(12_000, subnormal_at) + want = (len(rows), _digest([(r["k"], r["x"], r["n"], r["s"], r["b"], r["z"]) for r in rows])) # fmt: skip + type_name = "Part" if entry == "insert_many" else "PartV" + with arcadedb.create_database(temp_db_path) as db: + _types(db) + if entry == "insert_many": + assert db.insert_many("Part", rows) == len(rows) + else: + with db.graph_batch(parallel_flush=False) as batch: + assert len(batch.create_vertices("PartV", rows)) == len(rows) + assert _stored_digest(db, type_name) == want + with arcadedb.open_database(temp_db_path) as db: + assert _stored_digest(db, type_name) == want diff --git a/tests/test_graph_batch_single_pass.py b/tests/test_graph_batch_single_pass.py index a6aa6a9..67cc95d 100644 --- a/tests/test_graph_batch_single_pass.py +++ b/tests/test_graph_batch_single_pass.py @@ -26,11 +26,17 @@ def _rows(n): ] +@pytest.mark.parametrize("numpy_present", [True, False], ids=["numpy", "no_numpy"]) @pytest.mark.parametrize("chunk", [100_000, 500], ids=["one_chunk", "screened"]) -def test_scalar_rows_are_not_judged_value_by_value(temp_db_path, monkeypatch, chunk): +def test_scalar_rows_are_not_judged_value_by_value( + temp_db_path, monkeypatch, chunk, numpy_present +): + # the float column of `_rows` is screened in C with numpy or without it (#371: without numpy it was judged by a call per value) def refuse(value): raise AssertionError("a per-value gate ran over a scalar table") + if not numpy_present: + monkeypatch.setattr(tc, "_np", None) monkeypatch.setattr(GraphBatch, "_BULK_CHUNK", chunk) monkeypatch.setattr(tc, "json_bulk_scalar_ok", refuse) monkeypatch.setattr(gbm, "json_bulk_scalar_ok", refuse)