diff --git a/datasette/app.py b/datasette/app.py index 8a4b6011..f1fcc5eb 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -26,10 +26,9 @@ from .views.index import IndexView from .views.special import JsonDataView, PatternPortfolioView from .views.table import RowView, TableView from .renderer import json_renderer -from .database import Database +from .database import Database, QueryInterrupted from .utils import ( - QueryInterrupted, escape_css_string, escape_sqlite, format_bytes, diff --git a/datasette/database.py b/datasette/database.py index 0f540e01..e6154caa 100644 --- a/datasette/database.py +++ b/datasette/database.py @@ -8,8 +8,6 @@ import uuid from .tracer import trace from .utils import ( - QueryInterrupted, - Results, detect_fts, detect_primary_keys, detect_spatialite, @@ -371,3 +369,40 @@ class WriteTask: self.fn = fn self.task_id = task_id self.reply_queue = reply_queue + + +class QueryInterrupted(Exception): + pass + + +class MultipleValues(Exception): + pass + + +class Results: + def __init__(self, rows, truncated, description): + self.rows = rows + self.truncated = truncated + self.description = description + + @property + def columns(self): + return [d[0] for d in self.description] + + def first(self): + if self.rows: + return self.rows[0] + else: + return None + + def single_value(self): + if self.rows and 1 == len(self.rows) and 1 == len(self.rows[0]): + return self.rows[0][0] + else: + raise MultipleValues + + def __iter__(self): + return iter(self.rows) + + def __len__(self): + return len(self.rows) diff --git a/datasette/facets.py b/datasette/facets.py index 18558754..1712db9b 100644 --- a/datasette/facets.py +++ b/datasette/facets.py @@ -2,12 +2,12 @@ import json import urllib import re from datasette import hookimpl +from datasette.database import QueryInterrupted from datasette.utils import ( escape_sqlite, path_with_added_args, path_with_removed_args, detect_json1, - QueryInterrupted, InvalidSql, sqlite3, ) diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index f1c24041..26a778d3 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -47,27 +47,6 @@ ENV SQLITE_EXTENSIONS /usr/lib/x86_64-linux-gnu/mod_spatialite.so """ -class QueryInterrupted(Exception): - pass - - -class Results: - def __init__(self, rows, truncated, description): - self.rows = rows - self.truncated = truncated - self.description = description - - @property - def columns(self): - return [d[0] for d in self.description] - - def __iter__(self): - return iter(self.rows) - - def __len__(self): - return len(self.rows) - - def urlsafe_components(token): "Splits token on commas and URL decodes each component" return [urllib.parse.unquote_plus(b) for b in token.split(",")] diff --git a/datasette/views/base.py b/datasette/views/base.py index e2bce2f9..f5eafe63 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -10,8 +10,8 @@ import pint from datasette import __version__ from datasette.plugins import pm +from datasette.database import QueryInterrupted from datasette.utils import ( - QueryInterrupted, InvalidSql, LimitedWriter, is_url, diff --git a/datasette/views/table.py b/datasette/views/table.py index 10e86eeb..c07447d3 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -5,9 +5,9 @@ import json import jinja2 from datasette.plugins import pm +from datasette.database import QueryInterrupted from datasette.utils import ( CustomRow, - QueryInterrupted, RequestParameters, append_querystring, compound_keys_after_sql, diff --git a/docs/internals.rst b/docs/internals.rst index d7b6e7cb..0020f96d 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -94,13 +94,74 @@ Database class Instances of the ``Database`` class can be used to execute queries against attached SQLite databases, and to run introspection against their schemas. -SQLite only allows one database connection to write at a time. Datasette handles this for you by maintaining a queue of writes to be executed against a given database. Plugins can submit write operations to this queue and they will be executed in the order in which they are received. +.. _database_execute: + +await db.execute(sql, ...) +-------------------------- + +Executes a SQL query against the database and returns the resulting rows (see :ref:`database_results`). + +``sql`` - string (required) + The SQL query to execute. This can include ``?`` or ``:named`` parameters. + +``params`` - list or dict + A list or dictionary of values to use for the parameters. List for ``?``, dictionary for ``:named``. + +``truncate`` - boolean + Should the rows returned by the query be truncated at the maximum page size? Defaults to ``True``, set this to ``False`` to disable truncation. + +``custom_time_limit`` - integer ms + A custom time limit for this query. This can be set to a lower value than the Datasette configured default. If a query takes longer than this it will be terminated early and raise a ``dataette.database.QueryInterrupted`` exception. + +``page_size`` - integer + Set a custom page size for truncation, over-riding the configured Datasette default. + +``log_sql_errors`` - boolean + Should any SQL errors be logged to the console in addition to being raised as an error? Defaults to ``True``. + +.. _database_results: + +Results +------- + +The ``db.execute()`` method returns a single ``Results`` object. This can be used to access the rows returned by the query. + +Iterating over a ``Results`` object will yield SQLite `Row objects `__. Each of these can be treated as a tuple or can be accessed using ``row["column"]`` syntax: + +.. code-block:: python + + info = [] + results = await db.execute("select name from sqlite_master") + for row in results: + info.append(row["name"]) + +The ``Results`` object also has the following properties and methods: + +``.truncated`` - boolean + Indicates if this query was truncated - if it returned more results than the specified ``page_size``. If this is true then the results object will only provide access to the first ``page_size`` rows in the query result. You can disable truncation by passing ``truncate=False`` to the ``db.query()`` method. + +``.columns`` - list of strings + A list of column names returned by the query. + +``.rows`` - list of sqlite3.Row + This property provides direct access to the list of rows returned by the database. You can access specific rows by index using ``results.rows[0]``. + +``.first()`` - row or None + Returns the first row in the results, or ``None`` if no rows were returned. + +``.single_value()`` + Returns the value of the first column of the first row of results - but only if the query returned a single row with a single column. Raises a ``datasette.database.MultipleValues`` exception otherwise. + +``.__len__()`` + Calling ``len(results)`` returns the (truncated) number of returned results. .. _database_execute_write: await db.execute_write(sql, params=None, block=False) ----------------------------------------------------- +SQLite only allows one database connection to write at a time. Datasette handles this for you by maintaining a queue of writes to be executed against a given database. Plugins can submit write operations to this queue and they will be executed in the order in which they are received. + This method can be used to queue up a non-SELECT SQL query to be executed against a single write connection to the database. You can pass additional SQL parameters as a tuple or dictionary. diff --git a/tests/test_database.py b/tests/test_database.py index a9728019..d4055776 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -1,9 +1,47 @@ +from datasette.database import Results, MultipleValues +from datasette.utils import sqlite3 from .fixtures import app_client import pytest import time import uuid +@pytest.mark.asyncio +async def test_execute1(app_client): + db = app_client.ds.databases["fixtures"] + results = await db.execute("select * from facetable") + assert isinstance(results, Results) + assert 15 == len(results) + + +@pytest.mark.asyncio +async def test_results_first(app_client): + db = app_client.ds.databases["fixtures"] + assert None is (await db.execute("select * from facetable where pk > 100")).first() + results = await db.execute("select * from facetable") + row = results.first() + assert isinstance(row, sqlite3.Row) + + +@pytest.mark.parametrize( + "query,expected", + [ + ("select 1", 1), + ("select 1, 2", None), + ("select 1 as num union select 2 as num", None), + ], +) +@pytest.mark.asyncio +async def test_results_single_value(app_client, query, expected): + db = app_client.ds.databases["fixtures"] + results = await db.execute(query) + if expected: + assert expected == results.single_value() + else: + with pytest.raises(MultipleValues): + results.single_value() + + @pytest.mark.parametrize( "tables,exists", (