Compare commits

...

12 commits

Author SHA1 Message Date
Simon Willison
3f1e6cf12e Release 1.5 2019-07-14 21:34:49 -07:00
Simon Willison
b5a5df6d0e Documentation for .get() 2019-07-14 21:24:42 -07:00
Simon Willison
1445a8ecb5 Tests for .get() NotFoundError 2019-07-14 21:21:26 -07:00
Simon Willison
71a3bc26c9 Applied Black 2019-07-14 21:04:43 -07:00
Simon Willison
e51bd09a35 Python library docs for compound primary keys 2019-07-14 21:03:57 -07:00
Simon Willison
be9ff64940 Docs for compound primary keys with CLI 2019-07-14 21:00:12 -07:00
Simon Willison
f47b5a9e19 CLI now accepts multiple --pk for compound primary keys 2019-07-14 20:57:57 -07:00
Simon Willison
d5dc92876e create_table now handles compound primary keys, closes #36 2019-07-14 20:42:36 -07:00
Simon Willison
90edd0d817 Show version in docs
Thanks, @nedbat

https://twitter.com/nedbat/status/1150490436114534400
2019-07-14 20:42:36 -07:00
Simon Willison
0fcb1e4839 Sphinx docs now pull version info from git tag
Based on http://dreamiurg.net/2011/10/03/using-git-to-get-version-information/
2019-07-14 20:42:36 -07:00
Simon Willison
9ff905937f Use pip install -e .[docs] for documentation dependencies 2019-07-14 20:42:36 -07:00
Simon Willison
a9fd1e785f Initial implementation of table.get(...) 2019-07-14 12:22:43 -07:00
11 changed files with 205 additions and 21 deletions

View file

@ -2,6 +2,18 @@
Changelog Changelog
=========== ===========
.. _v1_5:
1.5 (2019-07-14)
----------------
- Support for compound primary keys (`#36 <https://github.com/simonw/sqlite-utils/issues/36>`__)
- Configure these using the CLI tool by passing ``--pk`` multiple times
- In Python, pass a tuple of columns to the ``pk=(..., ...)`` argument: see :ref:`python_api_compound_primary_keys`
- New ``table.get()`` method for retrieving a record by its primary key (`#39 <https://github.com/simonw/sqlite-utils/issues/39>`__) - :ref:`documentation <python_api_get>`
.. _v1_4_1: .. _v1_4_1:
1.4.1 (2019-07-14) 1.4.1 (2019-07-14)

View file

@ -200,6 +200,8 @@ Here's the simplest possible example::
To specify a column as the primary key, use ``--pk=column_name``. To specify a column as the primary key, use ``--pk=column_name``.
To create a compound primary key across more than one column, use ``--pk`` multiple times.
If you feed it a JSON list it will insert multiple records. For example, if ``dogs.json`` looks like this:: If you feed it a JSON list it will insert multiple records. For example, if ``dogs.json`` looks like this::
[ [

View file

@ -1,6 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
#
from subprocess import Popen, PIPE
# This file is execfile()d with the current directory set to its # This file is execfile()d with the current directory set to its
# containing dir. # containing dir.
# #
@ -52,9 +54,15 @@ author = "Simon Willison"
# built documents. # built documents.
# #
# The short X.Y version. # The short X.Y version.
version = "" pipe = Popen("git describe --tags --always", stdout=PIPE, shell=True)
# The full version, including alpha/beta/rc tags. git_version = pipe.stdout.read().decode("utf8")
release = ""
if git_version:
version = git_version.rsplit("-", 1)[0]
release = git_version
else:
version = ""
release = ""
# The language for content autogenerated by Sphinx. Refer to documentation # The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages. # for a list of supported languages.

View file

@ -1,6 +1,6 @@
============== =======================
sqlite-utils sqlite-utils |version|
============== =======================
*Python utility functions for manipulating SQLite databases* *Python utility functions for manipulating SQLite databases*

View file

@ -72,6 +72,21 @@ You can filter rows by a WHERE clause using ``.rows_where(where, where_args)``::
... print(row) ... print(row)
{'id': 1, 'age': 4, 'name': 'Cleo'} {'id': 1, 'age': 4, 'name': 'Cleo'}
.. _python_api_get:
Retrieving a specific record
============================
You can retrieve a record by its primary key using ``table.get()``::
>>> db = sqlite_utils.Database("dogs.db")
>>> print(db["dogs"].get(1))
{'id': 1, 'age': 4, 'name': 'Cleo'}
If the table has a compound primary key you can pass in the primary key values as a tuple::
>>> db["compound_dogs"].get(("mixed", 3))
Creating tables Creating tables
=============== ===============
@ -147,6 +162,24 @@ The first argument here is a dictionary specifying the columns you would like to
This method takes optional arguments ``pk=``, ``column_order=``, ``foreign_keys=``, ``not_null=set()`` and ``defaults=dict()`` - explained below. This method takes optional arguments ``pk=``, ``column_order=``, ``foreign_keys=``, ``not_null=set()`` and ``defaults=dict()`` - explained below.
.. _python_api_compound_primary_keys:
Compound primary keys
---------------------
If you want to create a table with a compound primary key that spans multiple columns, you can do so by passing a tuple of column names to any of the methods that accept a ``pk=`` parameter. For example:
.. code-block:: python
db["cats"].create({
"id": int,
"breed": str,
"name": str,
"weight": float,
}, pk=("breed", "id"))
This also works for the ``.insert()``, ``.insert_all()``, ``.upsert()`` and ``.upsert_all()`` methods.
.. _python_api_foreign_keys: .. _python_api_foreign_keys:
Specifying foreign keys Specifying foreign keys

View file

@ -2,7 +2,7 @@ from setuptools import setup, find_packages
import io import io
import os import os
VERSION = "1.4.1" VERSION = "1.5"
def get_long_description(): def get_long_description():
@ -24,7 +24,10 @@ setup(
packages=find_packages(exclude="tests"), packages=find_packages(exclude="tests"),
install_requires=["click", "click-default-group", "tabulate"], install_requires=["click", "click-default-group", "tabulate"],
setup_requires=["pytest-runner"], setup_requires=["pytest-runner"],
extras_require={"test": ["pytest", "black"]}, extras_require={
"test": ["pytest", "black"],
"docs": ["sphinx_rtd_theme", "sphinx-autobuild"],
},
entry_points=""" entry_points="""
[console_scripts] [console_scripts]
sqlite-utils=sqlite_utils.cli:cli sqlite-utils=sqlite_utils.cli:cli

View file

@ -302,7 +302,9 @@ def insert_upsert_options(fn):
), ),
click.argument("table"), click.argument("table"),
click.argument("json_file", type=click.File(), required=True), click.argument("json_file", type=click.File(), required=True),
click.option("--pk", help="Column to use as the primary key, e.g. id"), click.option(
"--pk", help="Columns to use as the primary key, e.g. id", multiple=True
),
click.option("--nl", is_flag=True, help="Expect newline-delimited JSON"), click.option("--nl", is_flag=True, help="Expect newline-delimited JSON"),
click.option("-c", "--csv", is_flag=True, help="Expect CSV"), click.option("-c", "--csv", is_flag=True, help="Expect CSV"),
click.option( click.option(
@ -348,6 +350,8 @@ def insert_upsert_implementation(
if nl and csv: if nl and csv:
click.echo("Use just one of --nl and --csv", err=True) click.echo("Use just one of --nl and --csv", err=True)
return return
if pk and len(pk) == 1:
pk = pk[0]
if csv: if csv:
reader = csv_std.reader(json_file) reader = csv_std.reader(json_file)
headers = next(reader) headers = next(reader)

View file

@ -79,6 +79,10 @@ class BadPrimaryKey(Exception):
pass pass
class NotFoundError(Exception):
pass
class Database: class Database:
def __init__(self, filename_or_conn): def __init__(self, filename_or_conn):
if isinstance(filename_or_conn, str): if isinstance(filename_or_conn, str):
@ -205,11 +209,15 @@ class Database:
raise AlterError( raise AlterError(
"No such column: {}.{}".format(fk.other_table, fk.other_column) "No such column: {}.{}".format(fk.other_table, fk.other_column)
) )
extra = ""
column_defs = [] column_defs = []
# ensure pk is a tuple
single_pk = None
if isinstance(pk, str):
single_pk = pk
for column_name, column_type in column_items: for column_name, column_type in column_items:
column_extras = [] column_extras = []
if pk == column_name: if column_name == single_pk:
column_extras.append("PRIMARY KEY") column_extras.append("PRIMARY KEY")
if column_name in not_null: if column_name in not_null:
column_extras.append("NOT NULL") column_extras.append("NOT NULL")
@ -233,12 +241,17 @@ class Database:
else "", else "",
) )
) )
extra_pk = ""
if single_pk is None and pk and len(pk) > 1:
extra_pk = ",\n PRIMARY KEY ({pks})".format(
pks=", ".join(["[{}]".format(p) for p in pk])
)
columns_sql = ",\n".join(column_defs) columns_sql = ",\n".join(column_defs)
sql = """CREATE TABLE [{table}] ( sql = """CREATE TABLE [{table}] (
{columns_sql} {columns_sql}{extra_pk}
){extra}; );
""".format( """.format(
table=name, columns_sql=columns_sql, extra=extra table=name, columns_sql=columns_sql, extra_pk=extra_pk
) )
self.conn.execute(sql) self.conn.execute(sql)
return self[name] return self[name]
@ -382,6 +395,37 @@ class Table:
def pks(self): def pks(self):
return [column.name for column in self.columns if column.is_pk] return [column.name for column in self.columns if column.is_pk]
def get(self, pk_values):
if not isinstance(pk_values, (list, tuple)):
pk_values = [pk_values]
pks = self.pks
pk_names = []
if len(pks) == 0:
# rowid table
pk_names = ["rowid"]
last_pk = pk_values[0]
elif len(pks) == 1:
pk_names = [pks[0]]
last_pk = pk_values[0]
elif len(pks) > 1:
pk_names = pks
last_pk = pk_values
if len(pk_names) != len(pk_values):
raise NotFoundError(
"Need {} primary key value{}".format(
len(pk_names), "" if len(pk_names) == 1 else "s"
)
)
wheres = ["[{}] = ?".format(pk_name) for pk_name in pk_names]
rows = self.rows_where(" and ".join(wheres), pk_values)
try:
row = list(rows)[0]
self.last_pk = last_pk
return row
except IndexError:
raise NotFoundError
@property @property
def foreign_keys(self): def foreign_keys(self):
fks = [] fks = []
@ -786,12 +830,13 @@ class Table:
self.last_pk = None self.last_pk = None
# self.last_rowid will be 0 if a "INSERT OR IGNORE" happened # self.last_rowid will be 0 if a "INSERT OR IGNORE" happened
if (hash_id or pk) and self.last_rowid: if (hash_id or pk) and self.last_rowid:
self.last_pk = self.db.conn.execute( row = list(self.rows_where("rowid = ?", [self.last_rowid]))[0]
"select [{}] from [{}] where rowid = ?".format( if hash_id:
hash_id or pk, self.name self.last_pk = row[hash_id]
), elif isinstance(pk, str):
(self.last_rowid,), self.last_pk = row[pk]
).fetchone()[0] else:
self.last_pk = tuple(row[p] for p in pk)
return self return self
def upsert( def upsert(

View file

@ -427,6 +427,31 @@ def test_insert_multiple_with_primary_key(db_path, tmpdir):
assert ["id"] == db["dogs"].pks assert ["id"] == db["dogs"].pks
def test_insert_multiple_with_compound_primary_key(db_path, tmpdir):
json_path = str(tmpdir / "dogs.json")
dogs = [
{"breed": "mixed", "id": i, "name": "Cleo {}".format(i), "age": i + 3}
for i in range(1, 21)
]
open(json_path, "w").write(json.dumps(dogs))
result = CliRunner().invoke(
cli.cli, ["insert", db_path, "dogs", json_path, "--pk", "id", "--pk", "breed"]
)
assert 0 == result.exit_code
db = Database(db_path)
assert dogs == db.execute_returning_dicts("select * from dogs order by breed, id")
assert {"breed", "id"} == set(db["dogs"].pks)
assert (
"CREATE TABLE [dogs] (\n"
" [breed] TEXT,\n"
" [id] INTEGER,\n"
" [name] TEXT,\n"
" [age] INTEGER,\n"
" PRIMARY KEY ([id], [breed])\n"
")"
) == db["dogs"].schema
def test_insert_not_null_default(db_path, tmpdir): def test_insert_not_null_default(db_path, tmpdir):
json_path = str(tmpdir / "dogs.json") json_path = str(tmpdir / "dogs.json")
dogs = [ dogs = [

View file

@ -55,6 +55,21 @@ def test_create_table(fresh_db):
) == table.schema ) == table.schema
def test_create_table_compound_primary_key(fresh_db):
table = fresh_db.create_table(
"test_table", {"id1": str, "id2": str, "value": int}, pk=("id1", "id2")
)
assert (
"CREATE TABLE [test_table] (\n"
" [id1] TEXT,\n"
" [id2] TEXT,\n"
" [value] INTEGER,\n"
" PRIMARY KEY ([id1], [id2])\n"
")"
) == table.schema
assert ["id1", "id2"] == table.pks
def test_create_table_with_bad_defaults(fresh_db): def test_create_table_with_bad_defaults(fresh_db):
with pytest.raises(AssertionError): with pytest.raises(AssertionError):
fresh_db.create_table( fresh_db.create_table(
@ -122,6 +137,13 @@ def test_create_table_from_example(fresh_db, example, expected_columns):
] ]
def test_create_table_from_example_with_compound_primary_keys(fresh_db):
record = {"name": "Zhang", "group": "staff", "employee_id": 2}
table = fresh_db["people"].insert(record, pk=("group", "employee_id"))
assert ["group", "employee_id"] == table.pks
assert record == table.get(("staff", 2))
def test_create_table_column_order(fresh_db): def test_create_table_column_order(fresh_db):
fresh_db["table"].insert( fresh_db["table"].insert(
collections.OrderedDict( collections.OrderedDict(

View file

@ -1,4 +1,34 @@
import pytest import pytest
from sqlite_utils.db import NotFoundError
def test_get_rowid(fresh_db):
dogs = fresh_db["dogs"]
cleo = {"name": "Cleo", "age": 4}
row_id = dogs.insert(cleo).last_rowid
assert cleo == dogs.get(row_id)
def test_get_primary_key(fresh_db):
dogs = fresh_db["dogs"]
cleo = {"name": "Cleo", "age": 4, "id": 5}
last_pk = dogs.insert(cleo, pk="id").last_pk
assert 5 == last_pk
assert cleo == dogs.get(5)
@pytest.mark.parametrize(
"argument,expected_msg",
[(100, None), (None, None), ((1, 2), "Need 1 primary key value"), ("2", None)],
)
def test_get_not_found(argument, expected_msg, fresh_db):
fresh_db["dogs"].insert(
{"id": 1, "name": "Cleo", "age": 4, "is_good": True}, pk="id"
)
with pytest.raises(NotFoundError) as excinfo:
fresh_db["dogs"].get(argument)
if expected_msg is not None:
assert expected_msg == excinfo.value.args[0]
@pytest.mark.parametrize( @pytest.mark.parametrize(