hash_id= mechanism for setting ID using content hash, closes #13

This commit is contained in:
Simon Willison 2019-02-23 20:36:40 -08:00
commit dbf239e3fc
3 changed files with 93 additions and 7 deletions

View file

@ -121,6 +121,10 @@ If you want to explicitly set the order of the columns you can do so using the `
You don't need to pass all of the columns to the ``column_order`` parameter. If you only pass a subset of the columns the remaining columns will be ordered based on the key order of the dictionary. You don't need to pass all of the columns to the ``column_order`` parameter. If you only pass a subset of the columns the remaining columns will be ordered based on the key order of the dictionary.
After inserting a row like this, the ``dogs.last_rowid`` property will return the SQLite ``rowid`` assigned to the most recently inserted record.
The ``dogs.last_pk`` property will return the last inserted primary key value, if you specified one. This can be very useful when writing code that creates foreign key or many-to-many relationships.
Bulk inserts Bulk inserts
============ ============
@ -178,6 +182,31 @@ Note that the ``pk`` and ``column_order`` parameters here are optional if you ar
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.
Setting an ID based on the hash of the row contents
===================================================
Sometimes you will find yourself working with a dataset that includes rows that do not have a provided obvious ID, but where you would like to assign one so that you can later upsert into that table without creating duplicate records.
In these cases, a useful technique is to create an ID that is derived from the sha1 hash of the row contents.
``sqlite-utils`` can do this for you using the ``hash_id=`` option. For example::
db = sqlite_utils.Database("dogs.db")
db["dogs"].upsert({"name": "Cleo", "twitter": "cleopaws"}, hash_id="id")
print(list(db["dogs]))
Outputs::
[{'id': 'f501265970505d9825d8d9f590bfab3519fb20b1', 'name': 'Cleo', 'twitter': 'cleopaws'}]
If you are going to use that ID straight away, you can access it using ``last_pk``::
dog_id = db["dogs"].upsert({
"name": "Cleo",
"twitter": "cleopaws"
}, hash_id="id").last_pk
# dog_id is now "f501265970505d9825d8d9f590bfab3519fb20b1"
Creating views Creating views
============== ==============

View file

@ -1,6 +1,7 @@
import sqlite3 import sqlite3
from collections import namedtuple from collections import namedtuple
import datetime import datetime
import hashlib
import itertools import itertools
import json import json
import pathlib import pathlib
@ -53,7 +54,7 @@ class Database:
return [dict(zip(keys, row)) for row in cursor.fetchall()] return [dict(zip(keys, row)) for row in cursor.fetchall()]
def create_table( def create_table(
self, name, columns, pk=None, foreign_keys=None, column_order=None self, name, columns, pk=None, foreign_keys=None, column_order=None, hash_id=None
): ):
foreign_keys = foreign_keys or [] foreign_keys = foreign_keys or []
foreign_keys_by_name = {fk[0]: fk for fk in foreign_keys} foreign_keys_by_name = {fk[0]: fk for fk in foreign_keys}
@ -62,6 +63,9 @@ class Database:
column_items.sort( column_items.sort(
key=lambda p: column_order.index(p[0]) if p[0] in column_order else 999 key=lambda p: column_order.index(p[0]) if p[0] in column_order else 999
) )
if hash_id:
column_items.insert(0, (hash_id, str))
pk = hash_id
extra = "" extra = ""
col_type_mapping = { col_type_mapping = {
float: "FLOAT", float: "FLOAT",
@ -216,7 +220,9 @@ class Table:
indexes.append(Index(**row)) indexes.append(Index(**row))
return indexes return indexes
def create(self, columns, pk=None, foreign_keys=None, column_order=None): def create(
self, columns, pk=None, foreign_keys=None, column_order=None, hash_id=None
):
columns = {name: value for (name, value) in columns.items()} columns = {name: value for (name, value) in columns.items()}
with self.db.conn: with self.db.conn:
self.db.create_table( self.db.create_table(
@ -225,6 +231,7 @@ class Table:
pk=pk, pk=pk,
foreign_keys=foreign_keys, foreign_keys=foreign_keys,
column_order=column_order, column_order=column_order,
hash_id=hash_id,
) )
self.exists = True self.exists = True
return self return self
@ -358,7 +365,13 @@ class Table:
return self.db.conn.execute(sql, (q,)).fetchall() return self.db.conn.execute(sql, (q,)).fetchall()
def insert( def insert(
self, record, pk=None, foreign_keys=None, upsert=False, column_order=None self,
record,
pk=None,
foreign_keys=None,
upsert=False,
column_order=None,
hash_id=None,
): ):
return self.insert_all( return self.insert_all(
[record], [record],
@ -366,6 +379,7 @@ class Table:
foreign_keys=foreign_keys, foreign_keys=foreign_keys,
upsert=upsert, upsert=upsert,
column_order=column_order, column_order=column_order,
hash_id=hash_id,
) )
def insert_all( def insert_all(
@ -376,12 +390,14 @@ class Table:
upsert=False, upsert=False,
batch_size=100, batch_size=100,
column_order=None, column_order=None,
hash_id=None,
): ):
""" """
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
that it creates (if table does not exist) has columns for ALL of that that it creates (if table does not exist) has columns for ALL of that
data data
""" """
assert not (hash_id and pk), "Use either pk= or hash_id="
all_columns = None all_columns = None
first = True first = True
for chunk in chunks(records, batch_size): for chunk in chunks(records, batch_size):
@ -394,11 +410,14 @@ class Table:
pk, pk,
foreign_keys, foreign_keys,
column_order=column_order, column_order=column_order,
hash_id=hash_id,
) )
all_columns = set() all_columns = set()
for record in chunk: for record in chunk:
all_columns.update(record.keys()) all_columns.update(record.keys())
all_columns = list(sorted(all_columns)) all_columns = list(sorted(all_columns))
if hash_id:
all_columns.insert(0, hash_id)
first = False first = False
sql = """ sql = """
INSERT {upsert} INTO [{table}] ({columns}) VALUES {rows}; INSERT {upsert} INTO [{table}] ({columns}) VALUES {rows};
@ -418,24 +437,44 @@ class Table:
values = [] values = []
for record in chunk: for record in chunk:
values.extend( values.extend(
jsonify_if_needed(record.get(key, None)) for key in all_columns jsonify_if_needed(
record.get(key, None if key != hash_id else _hash(record))
)
for key in all_columns
) )
with self.db.conn: with self.db.conn:
result = self.db.conn.execute(sql, values) result = self.db.conn.execute(sql, values)
self.last_id = result.lastrowid self.last_rowid = result.lastrowid
self.lash_pk = None
if hash_id or pk:
self.last_pk = self.db.conn.execute(
"select [{}] from [{}] where rowid = ?".format(
hash_id or pk, self.name
),
(self.last_rowid,),
).fetchone()[0]
return self return self
def upsert(self, record, pk=None, foreign_keys=None, column_order=None): def upsert(
self, record, pk=None, foreign_keys=None, column_order=None, hash_id=None
):
return self.insert( return self.insert(
record, record,
pk=pk, pk=pk,
foreign_keys=foreign_keys, foreign_keys=foreign_keys,
upsert=True, upsert=True,
column_order=column_order, column_order=column_order,
hash_id=hash_id,
) )
def upsert_all( def upsert_all(
self, records, pk=None, foreign_keys=None, column_order=None, batch_size=100 self,
records,
pk=None,
foreign_keys=None,
column_order=None,
batch_size=100,
hash_id=None,
): ):
return self.insert_all( return self.insert_all(
records, records,
@ -444,6 +483,7 @@ class Table:
column_order=column_order, column_order=column_order,
batch_size=100, batch_size=100,
upsert=True, upsert=True,
hash_id=hash_id,
) )
@ -458,3 +498,9 @@ def jsonify_if_needed(value):
return json.dumps(value) return json.dumps(value)
else: else:
return value return value
def _hash(record):
return hashlib.sha1(
json.dumps(record, separators=(",", ":"), sort_keys=True).encode("utf8")
).hexdigest()

View file

@ -206,6 +206,17 @@ def test_insert_thousands_ignores_extra_columns_after_first_100(fresh_db):
assert [{"i": 101, "word": None}] == rows assert [{"i": 101, "word": None}] == rows
def test_insert_hash_id(fresh_db):
dogs = fresh_db["dogs"]
id = dogs.upsert({"name": "Cleo", "twitter": "cleopaws"}, hash_id="id").last_pk
assert "f501265970505d9825d8d9f590bfab3519fb20b1" == id
assert 1 == dogs.count
# Upserting a second time should not create a new row
id2 = dogs.upsert({"name": "Cleo", "twitter": "cleopaws"}, hash_id="id").last_pk
assert "f501265970505d9825d8d9f590bfab3519fb20b1" == id2
assert 1 == dogs.count
def test_create_view(fresh_db): def test_create_view(fresh_db):
fresh_db["data"].insert({"foo": "foo", "bar": "bar"}) fresh_db["data"].insert({"foo": "foo", "bar": "bar"})
fresh_db.create_view("bar", "select bar from data") fresh_db.create_view("bar", "select bar from data")