diff --git a/docs/cli-reference.rst b/docs/cli-reference.rst index c53d64222..ad02cee8a 100644 --- a/docs/cli-reference.rst +++ b/docs/cli-reference.rst @@ -952,7 +952,7 @@ See :ref:`cli_create_table`. :: - Usage: sqlite-utils create-table [OPTIONS] PATH TABLE COLUMNS... + Usage: sqlite-utils create-table [OPTIONS] PATH TABLE [COLUMNS]... Add a table with the specified columns. Columns should be specified using name, type pairs, for example: @@ -965,7 +965,13 @@ See :ref:`cli_create_table`. Valid column types are text, integer, real, float, blob and any. + Use --sql to create the table from the results of a SQL query instead: + + sqlite-utils create-table my.db tall_people \ + --sql "select * from people where height > 180" + Options: + --sql TEXT Create the table using the results of this SQL query --pk TEXT Column to use as primary key --not-null TEXT Columns that should be created as NOT NULL --default ... Default value that should be set for a column diff --git a/docs/cli.rst b/docs/cli.rst index 78c33b8ae..1df8b97fe 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -2167,6 +2167,15 @@ Use the ``any`` type for a strict column that should accept integers, floating p "name" TEXT ) STRICT +Instead of listing columns you can populate a new table from the results of a SQL query using ``--sql``. This is a shortcut for ``CREATE TABLE name AS ...``: + +.. code-block:: bash + + sqlite-utils create-table mydb.db tall_people \ + --sql "select * from people where height > 180" + +The new table takes its columns from the query, so ``--sql`` cannot be combined with column definitions or with the ``--pk``, ``--not-null``, ``--default``, ``--fk``, ``--transform`` and ``--strict`` options. It does work with ``--ignore`` and ``--replace``. + If a table with the same name already exists, you will get an error. You can choose to silently ignore this error with ``--ignore``, or you can replace the existing table with a new, empty table using ``--replace``. You can also pass ``--transform`` to transform the existing table to match the new schema. See :ref:`python_api_explicit_create` in the Python library documentation for details of how this option works. diff --git a/sqlite_utils/cli.py b/sqlite_utils/cli.py index c23090283..4cf7b6806 100644 --- a/sqlite_utils/cli.py +++ b/sqlite_utils/cli.py @@ -1703,7 +1703,11 @@ def create_database(path, enable_wal, init_spatialite, load_extension): required=True, ) @click.argument("table") -@click.argument("columns", nargs=-1, required=True) +@click.argument("columns", nargs=-1) +@click.option( + "--sql", + help="Create the table using the results of this SQL query", +) @click.option("pks", "--pk", help="Column to use as primary key", multiple=True) @click.option( "--not-null", @@ -1747,6 +1751,7 @@ def create_table( path, table, columns, + sql, pks, not_null, default, @@ -1769,10 +1774,52 @@ def create_table( photo blob --pk id Valid column types are text, integer, real, float, blob and any. + + Use --sql to create the table from the results of a SQL query instead: + + \b + sqlite-utils create-table my.db tall_people \\ + --sql "select * from people where height > 180" """ db = sqlite_utils.Database(path) _register_db_for_cleanup(db) _load_extensions(db, load_extension) + if sql is not None: + if columns: + raise click.ClickException("Cannot use columns with --sql") + incompatible = [ + option + for option, value in ( + ("--pk", pks), + ("--not-null", not_null), + ("--default", default), + ("--fk", fk), + ("--transform", transform), + ("--strict", strict), + ) + if value + ] + if incompatible: + raise click.ClickException( + "Cannot use {} with --sql".format(", ".join(incompatible)) + ) + if table in db.table_names(): + if ignore: + return + elif replace: + db[table].drop() + else: + raise click.ClickException( + f'Table "{table}" already exists. Use --replace to delete and replace it.' + ) + db.execute( + "CREATE TABLE {table} AS {sql}".format( + table=quote_identifier(table), sql=sql + ) + ) + return + if not columns: + raise click.ClickException("Provide columns or use --sql") if len(columns) % 2 == 1: raise click.ClickException( "columns must be an even number of 'name' 'type' pairs" diff --git a/tests/test_cli.py b/tests/test_cli.py index 064026a0b..d5e96c23b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1509,6 +1509,113 @@ def test_create_table_replace(): assert 'CREATE TABLE "dogs" (\n "id" INTEGER\n)' == db.table("dogs").schema +def test_create_table_sql(): + runner = CliRunner() + with runner.isolated_filesystem(): + db = Database("test.db") + db["people"].insert_all( + [ + {"id": 1, "name": "Ann", "height": 190}, + {"id": 2, "name": "Bob", "height": 150}, + ], + pk="id", + ) + result = runner.invoke( + cli.cli, + [ + "create-table", + "test.db", + "tall", + "--sql", + "select id, name from people where height > 180", + ], + catch_exceptions=False, + ) + assert result.exit_code == 0 + assert "CREATE TABLE tall(id INT,name TEXT)" == db["tall"].schema + assert [{"id": 1, "name": "Ann"}] == list(db["tall"].rows) + + +def test_create_table_sql_error_if_table_exists(): + runner = CliRunner() + with runner.isolated_filesystem(): + db = Database("test.db") + db["dogs"].insert({"name": "Cleo"}) + result = runner.invoke( + cli.cli, ["create-table", "test.db", "dogs", "--sql", "select 1"] + ) + assert result.exit_code == 1 + assert ( + 'Error: Table "dogs" already exists. Use --replace to delete and replace it.' + == result.output.strip() + ) + + +def test_create_table_sql_ignore(): + runner = CliRunner() + with runner.isolated_filesystem(): + db = Database("test.db") + db["dogs"].insert({"name": "Cleo"}) + result = runner.invoke( + cli.cli, + ["create-table", "test.db", "dogs", "--sql", "select 1 as id", "--ignore"], + ) + assert result.exit_code == 0 + assert 'CREATE TABLE "dogs" (\n "name" TEXT\n)' == db["dogs"].schema + + +def test_create_table_sql_replace(): + runner = CliRunner() + with runner.isolated_filesystem(): + db = Database("test.db") + db["dogs"].insert({"name": "Cleo"}) + result = runner.invoke( + cli.cli, + ["create-table", "test.db", "dogs", "--sql", "select 1 as id", "--replace"], + ) + assert result.exit_code == 0 + assert "CREATE TABLE dogs(id)" == db["dogs"].schema + + +def test_create_table_sql_requires_columns_or_sql(): + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke(cli.cli, ["create-table", "test.db", "t"]) + assert result.exit_code == 1 + assert "Error: Provide columns or use --sql" == result.output.strip() + + +def test_create_table_sql_conflicts_with_columns(): + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke( + cli.cli, + ["create-table", "test.db", "t", "id", "integer", "--sql", "select 1"], + ) + assert result.exit_code == 1 + assert "Error: Cannot use columns with --sql" == result.output.strip() + + +@pytest.mark.parametrize( + "extra_args,expected", + [ + (["--pk", "id"], "Error: Cannot use --pk with --sql"), + (["--not-null", "id"], "Error: Cannot use --not-null with --sql"), + (["--default", "id", "1"], "Error: Cannot use --default with --sql"), + (["--strict"], "Error: Cannot use --strict with --sql"), + ], +) +def test_create_table_sql_conflicts_with_options(extra_args, expected): + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke( + cli.cli, + ["create-table", "test.db", "t", "--sql", "select 1 as id"] + extra_args, + ) + assert result.exit_code == 1 + assert expected == result.output.strip() + + def test_create_view(): runner = CliRunner() with runner.isolated_filesystem():