Compare commits

...

4 commits

Author SHA1 Message Date
Simon Willison
ab8a4bda75 Documentation for new upsert v.s insert-replace
Refs #66
2019-12-29 21:23:58 -08:00
Simon Willison
4a2244b3e3 New upsert implementation, refs #66 2019-12-29 21:03:43 -08:00
Simon Willison
79cc8b854c Ran black, plus added comments for next step
Refs #66
2019-12-27 09:49:47 +00:00
Simon Willison
866a5bc487 insert --replace and insert(..., replace=True)
Refs #66
2019-12-27 09:49:47 +00:00
7 changed files with 257 additions and 76 deletions

View file

@ -265,18 +265,41 @@ For tab-delimited data, use ``--tsv``::
$ sqlite-utils insert dogs.db dogs docs.tsv --tsv $ sqlite-utils insert dogs.db dogs docs.tsv --tsv
Upserting data .. _cli_insert_replace:
==============
Upserting works exactly like inserting, with the exception that if your data has a primary key that matches an already exsting record that record will be replaced with the new data. Insert-replacing data
=====================
Insert-replacing works exactly like inserting, with the exception that if your data has a primary key that matches an already exsting record that record will be replaced with the new data.
After running the above ``dogs.json`` example, try running this:: After running the above ``dogs.json`` example, try running this::
$ echo '{"id": 2, "name": "Pancakes", "age": 3}' | \ $ echo '{"id": 2, "name": "Pancakes", "age": 3}' | \
sqlite-utils upsert dogs.db dogs - --pk=id sqlite-utils insert dogs.db dogs - --pk=id --replace
This will replace the record for id=2 (Pancakes) with a new record with an updated age. This will replace the record for id=2 (Pancakes) with a new record with an updated age.
.. _cli_upsert:
Upserting data
==============
Upserting is update-or-insert. If a row exists with the specified primary key the provided columns will be updated. If no row exists that row will be created.
Unlike ``insert --replace``, an upsert will ignore any column values that exist but are not present in the upsert document.
For example::
$ echo '{"id": 2, "age": 4}' | \
sqlite-utils upsert dogs.db dogs - --pk=id
This will update the dog with id=2 to have an age of 4, creating a new record (with a null name) if one does not exist. If a row DOES exist the name will be left as-is.
The command will fail if you reference columns that do not exist on the table. To automatically create missing columns, use the ``--alter`` option.
.. note::
``upsert`` in sqlite-utils 1.x worked like ``insert ... --replace`` does in 2.x. See `issue #66 <https://github.com/simonw/sqlite-utils/issues/66>`__ for details of this change.
.. _cli_add_column: .. _cli_add_column:
Adding columns Adding columns

View file

@ -358,6 +358,30 @@ The function can accept an iterator or generator of rows and will commit them ac
You can skip inserting any records that have a primary key that already exists using ``ignore=True``. This works with both ``.insert({...}, ignore=True)`` and ``.insert_all([...], ignore=True)``. You can skip inserting any records that have a primary key that already exists using ``ignore=True``. This works with both ``.insert({...}, ignore=True)`` and ``.insert_all([...], ignore=True)``.
.. _python_api_insert_replace:
Insert-replacing data
=====================
If you want to insert a record or replace an existing record with the same primary key, using the ``replace=True`` argument to ``.insert()`` or ``.insert_all()``::
dogs.insert_all([{
"id": 1,
"name": "Cleo",
"twitter": "cleopaws",
"age": 3,
"is_good_dog": True,
}, {
"id": 2,
"name": "Marnie",
"twitter": "MarnieTheDog",
"age": 16,
"is_good_dog": True,
}], pk="id", replace=True)
.. note::
Prior to sqlite-utils 2.x the ``.upsert()`` and ``.upsert_all()`` methods did this. See :ref:`python_api_upsert` for the new behaviour of those methods in 2.x.
.. _python_api_update: .. _python_api_update:
Updating a specific record Updating a specific record
@ -409,6 +433,8 @@ You can delete all records in a table that match a specific WHERE statement usin
Calling ``table.delete_where()`` with no other arguments will delete every row in the table. Calling ``table.delete_where()`` with no other arguments will delete every row in the table.
.. _python_api_upsert:
Upserting data Upserting data
============== ==============
@ -428,10 +454,15 @@ For example, given the dogs database you could upsert the record for Cleo like s
If a record exists with id=1, it will be updated to match those fields. If it does not exist it will be created. If a record exists with id=1, it will be updated to match those fields. If it does not exist it will be created.
Any existing columns that are not referenced in the dictionary passed to ``.upsert()`` will be unchanged. If you want to replace a record entirely, use ``.insert(doc, replace=True)`` instead.
Note that the ``pk`` and ``column_order`` parameters here are optional if you are certain that the table has already been created. You should pass them if the table may not exist at the time the first upsert is performed. Note that the ``pk`` and ``column_order`` parameters here are optional if you are certain that the table has already been created. You should pass them if the table may not exist at the time the first upsert is performed.
An ``upsert_all()`` method is also available, which behaves like ``insert_all()`` but performs upserts instead. An ``upsert_all()`` method is also available, which behaves like ``insert_all()`` but performs upserts instead.
.. note::
``.upsert()`` and ``.upsert_all()`` in sqlite-utils 1.x worked like ``.insert(..., replace=True)`` and ``.insert_all(..., replace=True)`` do in 2.x. See `issue #66 <https://github.com/simonw/sqlite-utils/issues/66>`__ for details of this change.
.. _python_api_lookup_tables: .. _python_api_lookup_tables:
Working with lookup tables Working with lookup tables

View file

@ -353,6 +353,7 @@ def insert_upsert_implementation(
alter, alter,
upsert, upsert,
ignore=False, ignore=False,
replace=False,
not_null=None, not_null=None,
default=None, default=None,
): ):
@ -372,17 +373,16 @@ def insert_upsert_implementation(
docs = json.load(json_file) docs = json.load(json_file)
if isinstance(docs, dict): if isinstance(docs, dict):
docs = [docs] docs = [docs]
if upsert: extra_kwargs = {"ignore": ignore, "replace": replace}
method = db[table].upsert_all
extra_kwargs = {}
else:
method = db[table].insert_all
extra_kwargs = {"ignore": ignore}
if not_null: if not_null:
extra_kwargs["not_null"] = set(not_null) extra_kwargs["not_null"] = set(not_null)
if default: if default:
extra_kwargs["defaults"] = dict(default) extra_kwargs["defaults"] = dict(default)
method(docs, pk=pk, batch_size=batch_size, alter=alter, **extra_kwargs) if upsert:
extra_kwargs["upsert"] = upsert
db[table].insert_all(
docs, pk=pk, batch_size=batch_size, alter=alter, **extra_kwargs
)
@cli.command() @cli.command()
@ -390,6 +390,12 @@ def insert_upsert_implementation(
@click.option( @click.option(
"--ignore", is_flag=True, default=False, help="Ignore records if pk already exists" "--ignore", is_flag=True, default=False, help="Ignore records if pk already exists"
) )
@click.option(
"--replace",
is_flag=True,
default=False,
help="Replace records if pk already exists",
)
def insert( def insert(
path, path,
table, table,
@ -401,6 +407,7 @@ def insert(
batch_size, batch_size,
alter, alter,
ignore, ignore,
replace,
not_null, not_null,
default, default,
): ):
@ -422,6 +429,7 @@ def insert(
alter=alter, alter=alter,
upsert=False, upsert=False,
ignore=ignore, ignore=ignore,
replace=replace,
not_null=not_null, not_null=not_null,
default=default, default=default,
) )

View file

@ -473,11 +473,11 @@ class Table(Queryable):
column_order=None, column_order=None,
not_null=None, not_null=None,
defaults=None, defaults=None,
upsert=False,
batch_size=100, batch_size=100,
hash_id=None, hash_id=None,
alter=False, alter=False,
ignore=False, ignore=False,
replace=False,
extracts=None, extracts=None,
): ):
super().__init__(db, name) super().__init__(db, name)
@ -488,11 +488,11 @@ class Table(Queryable):
column_order=column_order, column_order=column_order,
not_null=not_null, not_null=not_null,
defaults=defaults, defaults=defaults,
upsert=upsert,
batch_size=batch_size, batch_size=batch_size,
hash_id=hash_id, hash_id=hash_id,
alter=alter, alter=alter,
ignore=ignore, ignore=ignore,
replace=replace,
extracts=extracts, extracts=extracts,
) )
@ -915,10 +915,10 @@ class Table(Queryable):
column_order=DEFAULT, column_order=DEFAULT,
not_null=DEFAULT, not_null=DEFAULT,
defaults=DEFAULT, defaults=DEFAULT,
upsert=DEFAULT,
hash_id=DEFAULT, hash_id=DEFAULT,
alter=DEFAULT, alter=DEFAULT,
ignore=DEFAULT, ignore=DEFAULT,
replace=DEFAULT,
extracts=DEFAULT, extracts=DEFAULT,
): ):
return self.insert_all( return self.insert_all(
@ -928,10 +928,10 @@ class Table(Queryable):
column_order=column_order, column_order=column_order,
not_null=not_null, not_null=not_null,
defaults=defaults, defaults=defaults,
upsert=upsert,
hash_id=hash_id, hash_id=hash_id,
alter=alter, alter=alter,
ignore=ignore, ignore=ignore,
replace=replace,
extracts=extracts, extracts=extracts,
) )
@ -943,12 +943,13 @@ class Table(Queryable):
column_order=DEFAULT, column_order=DEFAULT,
not_null=DEFAULT, not_null=DEFAULT,
defaults=DEFAULT, defaults=DEFAULT,
upsert=DEFAULT,
batch_size=DEFAULT, batch_size=DEFAULT,
hash_id=DEFAULT, hash_id=DEFAULT,
alter=DEFAULT, alter=DEFAULT,
ignore=DEFAULT, ignore=DEFAULT,
replace=DEFAULT,
extracts=DEFAULT, extracts=DEFAULT,
upsert=False,
): ):
""" """
Like .insert() but takes a list of records and ensures that the table Like .insert() but takes a list of records and ensures that the table
@ -960,17 +961,17 @@ class Table(Queryable):
column_order = self.value_or_default("column_order", column_order) column_order = self.value_or_default("column_order", column_order)
not_null = self.value_or_default("not_null", not_null) not_null = self.value_or_default("not_null", not_null)
defaults = self.value_or_default("defaults", defaults) defaults = self.value_or_default("defaults", defaults)
upsert = self.value_or_default("upsert", upsert)
batch_size = self.value_or_default("batch_size", batch_size) batch_size = self.value_or_default("batch_size", batch_size)
hash_id = self.value_or_default("hash_id", hash_id) hash_id = self.value_or_default("hash_id", hash_id)
alter = self.value_or_default("alter", alter) alter = self.value_or_default("alter", alter)
ignore = self.value_or_default("ignore", ignore) ignore = self.value_or_default("ignore", ignore)
replace = self.value_or_default("replace", replace)
extracts = self.value_or_default("extracts", extracts) extracts = self.value_or_default("extracts", extracts)
assert not (hash_id and pk), "Use either pk= or hash_id=" assert not (hash_id and pk), "Use either pk= or hash_id="
assert not ( assert not (
ignore and upsert ignore and replace
), "Use either ignore=True or upsert=True, not both" ), "Use either ignore=True or replace=True, not both"
all_columns = None all_columns = None
first = True first = True
# We can only handle a max of 999 variables in a SQL insert, so # We can only handle a max of 999 variables in a SQL insert, so
@ -1008,8 +1009,57 @@ class Table(Queryable):
if hash_id: if hash_id:
all_columns.insert(0, hash_id) all_columns.insert(0, hash_id)
first = False first = False
or_what = ""
# values is the list of insert data that is passed to the
# .execute() method - but some of them may be replaced by
# new primary keys if we are extracting any columns.
values = []
extracts = resolve_extracts(extracts)
for record in chunk:
record_values = []
for key in all_columns:
value = jsonify_if_needed(
record.get(key, None if key != hash_id else _hash(record))
)
if key in extracts:
extract_table = extracts[key]
value = self.db[extract_table].lookup({"value": value})
record_values.append(value)
values.append(record_values)
queries_and_params = []
if upsert: if upsert:
if isinstance(pk, str):
pks = [pk]
else:
pks = pk
for record_values in values:
# TODO: make more efficient:
record = dict(zip(all_columns, record_values))
params = []
sql = "INSERT OR IGNORE INTO [{table}]({pks}) VALUES({pk_placeholders});".format(
table=self.name,
pks=", ".join(["[{}]".format(p) for p in pks]),
pk_placeholders=", ".join(["?" for p in pks]),
)
queries_and_params.append((sql, [record[col] for col in pks]))
# UPDATE [book] SET [name] = 'Programming' WHERE [id] = 1001;
set_cols = [col for col in all_columns if col not in pks]
sql2 = "UPDATE [{table}] SET {pairs} WHERE {wheres}".format(
table=self.name,
pairs=", ".join("[{}] = ?".format(col) for col in set_cols),
wheres=" AND ".join("[{}] = ?".format(pk) for pk in pks),
)
queries_and_params.append(
(
sql2,
[record[col] for col in set_cols]
+ [record[pk] for pk in pks],
)
)
else:
or_what = ""
if replace:
or_what = "OR REPLACE " or_what = "OR REPLACE "
elif ignore: elif ignore:
or_what = "OR IGNORE " or_what = "OR IGNORE "
@ -1028,27 +1078,18 @@ class Table(Queryable):
for record in chunk for record in chunk
), ),
) )
values = [] flat_values = list(itertools.chain(*values))
extracts = resolve_extracts(extracts) queries_and_params = [(sql, flat_values)]
for record in chunk:
record_values = []
for key in all_columns:
value = jsonify_if_needed(
record.get(key, None if key != hash_id else _hash(record))
)
if key in extracts:
extract_table = extracts[key]
value = self.db[extract_table].lookup({"value": value})
record_values.append(value)
values.extend(record_values)
with self.db.conn: with self.db.conn:
for query, params in queries_and_params:
try: try:
result = self.db.conn.execute(sql, values) result = self.db.conn.execute(query, params)
except OperationalError as e: except OperationalError as e:
if alter and (" column" in e.args[0]): if alter and (" column" in e.args[0]):
# Attempt to add any missing columns, then try again # Attempt to add any missing columns, then try again
self.add_missing_columns(chunk) self.add_missing_columns(chunk)
result = self.db.conn.execute(sql, values) result = self.db.conn.execute(query, params)
else: else:
raise raise
self.last_rowid = result.lastrowid self.last_rowid = result.lastrowid
@ -1076,8 +1117,8 @@ class Table(Queryable):
alter=DEFAULT, alter=DEFAULT,
extracts=DEFAULT, extracts=DEFAULT,
): ):
return self.insert( return self.upsert_all(
record, [record],
pk=pk, pk=pk,
foreign_keys=foreign_keys, foreign_keys=foreign_keys,
column_order=column_order, column_order=column_order,
@ -1085,7 +1126,6 @@ class Table(Queryable):
defaults=defaults, defaults=defaults,
hash_id=hash_id, hash_id=hash_id,
alter=alter, alter=alter,
upsert=True,
extracts=extracts, extracts=extracts,
) )
@ -1102,6 +1142,9 @@ class Table(Queryable):
alter=DEFAULT, alter=DEFAULT,
extracts=DEFAULT, extracts=DEFAULT,
): ):
# Perform the following for each record:
# INSERT OR IGNORE INTO books(id) VALUES(1001);
# UPDATE books SET name = 'Programming' WHERE id = 1001;
return self.insert_all( return self.insert_all(
records, records,
pk=pk, pk=pk,
@ -1109,11 +1152,11 @@ class Table(Queryable):
column_order=column_order, column_order=column_order,
not_null=not_null, not_null=not_null,
defaults=defaults, defaults=defaults,
batch_size=100, batch_size=batch_size,
hash_id=hash_id, hash_id=hash_id,
alter=alter, alter=alter,
upsert=True,
extracts=extracts, extracts=extracts,
upsert=True,
) )
def add_missing_columns(self, records): def add_missing_columns(self, records):
@ -1183,20 +1226,22 @@ class Table(Queryable):
) )
# Ensure each record exists in other table # Ensure each record exists in other table
for record in records: for record in records:
id = other_table.upsert(record, pk=pk).last_pk id = other_table.insert(record, pk=pk, replace=True).last_pk
m2m_table.upsert( m2m_table.insert(
{ {
"{}_id".format(other_table.name): id, "{}_id".format(other_table.name): id,
"{}_id".format(self.name): our_id, "{}_id".format(self.name): our_id,
} },
replace=True,
) )
else: else:
id = other_table.lookup(lookup) id = other_table.lookup(lookup)
m2m_table.upsert( m2m_table.insert(
{ {
"{}_id".format(other_table.name): id, "{}_id".format(other_table.name): id,
"{}_id".format(self.name): our_id, "{}_id".format(self.name): our_id,
} },
replace=True,
) )
return self return self

View file

@ -581,23 +581,23 @@ def test_only_allow_one_of_nl_tsv_csv(options, db_path, tmpdir):
assert "Error: Use just one of --nl, --csv or --tsv" == result.output.strip() assert "Error: Use just one of --nl, --csv or --tsv" == result.output.strip()
def test_upsert(db_path, tmpdir): def test_insert_replace(db_path, tmpdir):
test_insert_multiple_with_primary_key(db_path, tmpdir) test_insert_multiple_with_primary_key(db_path, tmpdir)
json_path = str(tmpdir / "upsert.json") json_path = str(tmpdir / "insert-replace.json")
db = Database(db_path) db = Database(db_path)
assert 20 == db["dogs"].count assert 20 == db["dogs"].count
upsert_dogs = [ insert_replace_dogs = [
{"id": 1, "name": "Upserted 1", "age": 4}, {"id": 1, "name": "Insert replaced 1", "age": 4},
{"id": 2, "name": "Upserted 2", "age": 4}, {"id": 2, "name": "Insert replaced 2", "age": 4},
{"id": 21, "name": "Fresh insert 21", "age": 6}, {"id": 21, "name": "Fresh insert 21", "age": 6},
] ]
open(json_path, "w").write(json.dumps(upsert_dogs)) open(json_path, "w").write(json.dumps(insert_replace_dogs))
result = CliRunner().invoke( result = CliRunner().invoke(
cli.cli, ["upsert", db_path, "dogs", json_path, "--pk", "id"] cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id", "--replace"]
) )
assert 0 == result.exit_code assert 0 == result.exit_code, result.output
assert 21 == db["dogs"].count assert 21 == db["dogs"].count
assert upsert_dogs == db.execute_returning_dicts( assert insert_replace_dogs == db.execute_returning_dicts(
"select * from dogs where id in (1, 2, 21) order by id" "select * from dogs where id in (1, 2, 21) order by id"
) )
@ -765,3 +765,59 @@ def test_rows(db_path, args, expected):
) )
result = CliRunner().invoke(cli.cli, ["rows", db_path, "dogs"] + args) result = CliRunner().invoke(cli.cli, ["rows", db_path, "dogs"] + args)
assert expected == result.output.strip() assert expected == result.output.strip()
def test_upsert(db_path, tmpdir):
json_path = str(tmpdir / "dogs.json")
db = Database(db_path)
insert_dogs = [
{"id": 1, "name": "Cleo", "age": 4},
{"id": 2, "name": "Nixie", "age": 4},
]
open(json_path, "w").write(json.dumps(insert_dogs))
result = CliRunner().invoke(
cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id"]
)
assert 0 == result.exit_code, result.output
assert 2 == db["dogs"].count
# Now run the upsert to update just their ages
upsert_dogs = [
{"id": 1, "age": 5},
{"id": 2, "age": 5},
]
open(json_path, "w").write(json.dumps(insert_dogs))
result = CliRunner().invoke(
cli.cli, ["upsert", db_path, "dogs", json_path, "--pk", "id"]
)
assert 0 == result.exit_code, result.output
assert [
{"id": 1, "name": "Cleo", "age": 4},
{"id": 2, "name": "Nixie", "age": 4},
] == db.execute_returning_dicts("select * from dogs order by id")
def test_upsert_alter(db_path, tmpdir):
json_path = str(tmpdir / "dogs.json")
db = Database(db_path)
insert_dogs = [{"id": 1, "name": "Cleo"}]
open(json_path, "w").write(json.dumps(insert_dogs))
result = CliRunner().invoke(
cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id"]
)
assert 0 == result.exit_code, result.output
# Should fail with error code if no --alter
upsert_dogs = [{"id": 1, "age": 5}]
open(json_path, "w").write(json.dumps(upsert_dogs))
result = CliRunner().invoke(
cli.cli, ["upsert", db_path, "dogs", json_path, "--pk", "id"]
)
assert 1 == result.exit_code
assert "no such column: age" == str(result.exception)
# Should succeed with --alter
result = CliRunner().invoke(
cli.cli, ["upsert", db_path, "dogs", json_path, "--pk", "id", "--alter"]
)
assert 0 == result.exit_code
assert [{"id": 1, "name": "Cleo", "age": 5},] == db.execute_returning_dicts(
"select * from dogs order by id"
)

View file

@ -445,7 +445,7 @@ def test_insert_row_alter_table(
@pytest.mark.parametrize("use_table_factory", [True, False]) @pytest.mark.parametrize("use_table_factory", [True, False])
def test_upsert_rows_alter_table(fresh_db, use_table_factory): def test_insert_replace_rows_alter_table(fresh_db, use_table_factory):
first_row = {"id": 1, "title": "Hedgehogs of the world", "author_id": 1} first_row = {"id": 1, "title": "Hedgehogs of the world", "author_id": 1}
next_rows = [ next_rows = [
{"id": 1, "title": "Hedgehogs of the World", "species": "hedgehogs"}, {"id": 1, "title": "Hedgehogs of the World", "species": "hedgehogs"},
@ -459,11 +459,11 @@ def test_upsert_rows_alter_table(fresh_db, use_table_factory):
if use_table_factory: if use_table_factory:
table = fresh_db.table("books", pk="id", alter=True) table = fresh_db.table("books", pk="id", alter=True)
table.insert(first_row) table.insert(first_row)
table.upsert_all(next_rows) table.insert_all(next_rows, replace=True)
else: else:
table = fresh_db["books"] table = fresh_db["books"]
table.insert(first_row, pk="id") table.insert(first_row, pk="id")
table.upsert_all(next_rows, alter=True) table.insert_all(next_rows, alter=True, replace=True)
assert { assert {
"author_id": int, "author_id": int,
"id": int, "id": int,
@ -664,11 +664,13 @@ def test_insert_ignore(fresh_db):
def test_insert_hash_id(fresh_db): def test_insert_hash_id(fresh_db):
dogs = fresh_db["dogs"] dogs = fresh_db["dogs"]
id = dogs.upsert({"name": "Cleo", "twitter": "cleopaws"}, hash_id="id").last_pk id = dogs.insert({"name": "Cleo", "twitter": "cleopaws"}, hash_id="id").last_pk
assert "f501265970505d9825d8d9f590bfab3519fb20b1" == id assert "f501265970505d9825d8d9f590bfab3519fb20b1" == id
assert 1 == dogs.count assert 1 == dogs.count
# Upserting a second time should not create a new row # Insert replacing a second time should not create a new row
id2 = dogs.upsert({"name": "Cleo", "twitter": "cleopaws"}, hash_id="id").last_pk id2 = dogs.insert(
{"name": "Cleo", "twitter": "cleopaws"}, hash_id="id", replace=True
).last_pk
assert "f501265970505d9825d8d9f590bfab3519fb20b1" == id2 assert "f501265970505d9825d8d9f590bfab3519fb20b1" == id2
assert 1 == dogs.count assert 1 == dogs.count
@ -791,10 +793,10 @@ def test_drop_view(fresh_db):
assert [] == fresh_db.view_names() assert [] == fresh_db.view_names()
def test_insert_upsert_all_empty_list(fresh_db): def test_insert_all_empty_list(fresh_db):
fresh_db["t"].insert({"foo": 1}) fresh_db["t"].insert({"foo": 1})
assert 1 == fresh_db["t"].count assert 1 == fresh_db["t"].count
fresh_db["t"].insert_all([]) fresh_db["t"].insert_all([])
assert 1 == fresh_db["t"].count assert 1 == fresh_db["t"].count
fresh_db["t"].upsert_all([]) fresh_db["t"].insert_all([], replace=True)
assert 1 == fresh_db["t"].count assert 1 == fresh_db["t"].count

16
tests/test_upsert.py Normal file
View file

@ -0,0 +1,16 @@
def test_upsert(fresh_db):
table = fresh_db["table"]
table.insert({"id": 1, "name": "Cleo"}, pk="id")
table.upsert({"id": 1, "age": 5}, pk="id", alter=True)
assert [{"id": 1, "name": "Cleo", "age": 5}] == list(table.rows)
def test_upsert_all(fresh_db):
table = fresh_db["table"]
table.upsert_all([{"id": 1, "name": "Cleo"}, {"id": 2, "name": "Nixie"}], pk="id")
table.upsert_all([{"id": 1, "age": 5}, {"id": 2, "age": 5}], pk="id", alter=True)
assert [
{"id": 1, "name": "Cleo", "age": 5},
{"id": 2, "name": "Nixie", "age": 5},
] == list(table.rows)
assert 2 == table.last_pk