From dc6a494b2a17f4e79092cd32be0282b56ce3d770 Mon Sep 17 00:00:00 2001 From: Elliot Chance Date: Mon, 20 Jun 2022 18:06:55 -0400 Subject: [PATCH] Support for the V ORM DO NOT MERGE. This is partial support for the ORM. However, there are some challenges that need to be addressed before this can be properly reviewed and landed: 1. The ORM in V requires drivers to be hard-coded. See #90. 2. The Connection doesn't _really_ implement orm.Connection because the vsql Connection is required to be mut and the current interface definition does not allow this. 3. We need to create a new test suite for the ORM. `vsql/orm_test.v` filled with combinations of statements "sql" commands will work just fine. Specifically, we need to test different combinations of expressions and types. Fixes #90 --- Makefile | 3 + docs/features.rst | 1 + docs/orm.rst | 467 +++++++++++++++++++++++++++++++ docs/testing.rst | 10 + docs/v-client-library-docs.rst | 123 ++++---- examples/orm.v | 43 +++ vsql/connection.v | 21 ++ vsql/earley.v | 3 +- vsql/orm.v | 405 +++++++++++++++++++++++++++ vsql/orm_test.v | 370 ++++++++++++++++++++++++ vsql/row.v | 8 + vsql/std_names_and_identifiers.v | 2 +- vsql/table.v | 12 +- vsql/value.v | 48 ++++ 14 files changed, 1462 insertions(+), 54 deletions(-) create mode 100644 docs/orm.rst create mode 100644 examples/orm.v create mode 100644 vsql/orm.v create mode 100644 vsql/orm_test.v diff --git a/Makefile b/Makefile index 649b96e..3461fca 100644 --- a/Makefile +++ b/Makefile @@ -87,6 +87,9 @@ btree-test: oldv sql-test: oldv $(V) -stats $(BUILD_OPTIONS) test vsql/sql_test.v +orm-test: oldv + $(V) -stats $(BUILD_OPTIONS) test vsql/orm_test.v + # CLI Tests cli-test: bin/vsql diff --git a/docs/features.rst b/docs/features.rst index 3c8ebba..c3d2c0e 100644 --- a/docs/features.rst +++ b/docs/features.rst @@ -6,6 +6,7 @@ Features custom-functions.rst in-memory-database.rst + orm.rst prepared-statements.rst server.rst transactions.rst diff --git a/docs/orm.rst b/docs/orm.rst new file mode 100644 index 0000000..898145d --- /dev/null +++ b/docs/orm.rst @@ -0,0 +1,467 @@ +ORM +=== + +V provides an ORM with a simplified DSL influenced by both SQL and V to make it +easier and safer when dealing with common SQL database interactions. Since it's +part of the language there is nothing you need to install, however, you will +still need a database to interact with. These docs cover using V's ORM with +vsql. + +You can find the +`official docs for the ORM here `_. + +.. contents:: + +Opening A Database +------------------ + +To open a database file, you have to use the ``open_orm`` function: + +.. code-block:: text + + db := vsql.open_orm('test.vsql') + +If the file does not exist it will be created. The connection is designed to +work with V's ORM. It is safe to use the underlying vsql connection as well: + +.. code-block:: text + + db.connection().query('insert into ...') + +Struct Definition +----------------- + +Use a regular ``struct`` to define a row in a table: + +.. code-block:: text + + struct Product { + id int + product_name string + price f64 + } + +The table name is derived from the name of the struct, ``Product`` in this case. +Each field has an assumed SQL type based on the V type, see *Types* below for +more information. The types and behaviors can also be customized with +*Attributes*. + +Attributes +^^^^^^^^^^ + +Attributes can be used to customize SQL types and other behaviors: + +.. code-block:: text + + @[table: 'products'] + struct Product { + id int @[primary] + product_name string + price string @[sql_type: 'NUMERIC(5,2)'] + } + +.. list-table:: + :header-rows: 1 + + * - Attribute Example + - Description + + * - + .. code-block:: text + + @[default: '123'] + + - DEFAULT values are not supported in vsql. This will return an error. + + * - + .. code-block:: text + + @[primary] + + - Defined the fields for the ``PRIMARY KEY``. Composite PRIMARY KEYs are not + supported at this time. + + * - + .. code-block:: text + + @[sql: serial] + + - Is a short-hand to declare this field as both the PRIMARY KEY and have an + auto-incrementing value. This only works with integer types. + + * - + .. code-block:: text + + @[sql: i8] + + - Using a type name for ``sql`` (notice that is not a string) will cause it + to resolve a different SQL type from the V type, but based on the same + rules. For example ``quantity int`` would use an ``INTEGER``, but + ``quantity int @[sql: i8]`` would use a ``SMALLINT`` which is helpful if + you would like in the struct types to be different from the underlying SQL + types. You can fully customize the type with ``sql_type``. + + * - + .. code-block:: text + + @[sql: 'custom_name'] + + - Override the SQL field name. SQL expects that any non-quoted name is to be + converted to uppercase. This means that the exact column name will be + ``CUSTOM_NAME``. It is safe to use a reserved word (such as + ``@[sql: 'where']``), it will be automatically quoted. + + * - + .. code-block:: text + + @[sql: '\"custom_name\"'] + + - Override the SQL field name as case sensitive, and prevent it from + becoming ``CUSTOM_NAME``. + + * - + .. code-block:: text + + @[sql_type: 'NUMERIC(5,2)'] + + - Override the SQL type. Notice that this should not include any + ``NOT NULL`` clause as that will be appended if needed based on if the + field is optional. + + * - + .. code-block:: text + + @[table: 'table_name'] + + - Override the table name. This can only be attached to the struct and will + be ignored on fields. + + * - + .. code-block:: text + + @[unique] + + - UNIQUE indexes are not supported in vsql. This will return an error. + +Types +^^^^^ + +When ``sql_type`` is not used, the SQL type is derived from field type. +Although, this can be overridden by using a type in the ``sql`` attribute, the +same behavior applies. + +.. list-table:: + :header-rows: 1 + + * - V type + - SQL type + + * - ``bool`` + - ``BOOLEAN`` + + * - ``i8`` + - ``SMALLINT`` + + * - ``u8`` + - ``SMALLINT`` + + * - ``i16`` + - ``SMALLINT`` + + * - ``u16`` + - ``INTEGER`` + + * - ``int`` + - ``INTEGER`` + + * - ``i64`` + - ``BIGINT`` + + * - ``u32`` + - ``BIGINT`` + + * - ``u64`` + - ``NUMERIC(20)`` + + * - ``f32`` + - ``REAL`` + + * - ``f64`` + - ``DOUBLE PRECISION`` + + * - ``string`` + - ``VARCHAR(2048)``. This length is chosen based on + `orm.string_max_len `_. + + * - ``time.Time`` + - ``TIMESTAMP WITH TIME ZONE`` + + * - ``enum`` + - Enums are not currently supported. This will return an error. + +All types will be ``NOT NULL`` unless the field is optional. See *NULLs*. + +NULLs +^^^^^ + +All fields are ``NOT NULL`` unless the field is optional: + +.. code-block:: text + + struct Product { + product_name string // NOT NULL + price ?f64 // Allows NULL + } + +The ORM syntax uses V ``none`` keyword when dealing with NULLs: + +.. code-block:: text + + product := Product{'Ham Sandwhich', none} + sql db { + insert product into Product + } + + sql db { + select from Product where price is none + } + +It's important to make the distinction between V requiring the field be set and +the NOT NULL constraint. For example: + +.. code-block:: text + + product := Product{} + sql db { + insert product into Product + } + +Will not return an error because ``product_name`` is an empty string and not +``NULL``. + +Creating Tables +--------------- + +You can create a table directly from the struct definition: + +.. code-block:: text + + sql db { + create table Product + }! + +The table name, column names and types will be extracted from the fields and +attributes. + +Dropping Tables +--------------- + +To delete (drop) a table: + +.. code-block:: text + + sql db { + drop table Product + }! + +Manipulating Data +----------------- + +Inserting +^^^^^^^^^ + +Insert data by passing an struct: + +.. code-block:: text + + product := Product{1, 'Ice Cream', '5.99', 17} + sql db { + insert product into Product + }! + +Note: If a field is ``@[sql: serial]`` it will *always* be given the next value +from the sequence, even if the value for this field is provided. + +Updating +^^^^^^^^ + +.. code-block:: text + + sql db { + update Product set quantity = 16 where product_name == 'Ice Cream' + }! + +See *Expressions* for ``where``. + +Deleting +^^^^^^^^ + +.. code-block:: text + + sql db { + delete from Product where product_name == 'Ice Cream' + }! + +See *Expressions* for ``where``. + +Last ID +^^^^^^^ + +Is not supported yet. Calling ``last_id()`` will always return ``0``. + +Fetching Data +------------- + +Retrieve all rows in a table by omitting the ``where`` clause: + +.. code-block:: text + + rows := sql db { + select from Product + }! + + for row in rows { + println(row) + } + +Or supply a ``where`` that may return zero or more rows: + +.. code-block:: text + + rows := sql db { + select from Product where price > 5 + }! + +See *Expressions* for ``where``. + +Expressions +----------- + +The expressions syntax is designed to be as close to V syntax as possible: + +.. code-block:: text + + products := sql db { + select from Product where price < 3.47 + }! + +Where ``price < 3.47`` is the expression. + +The type of the right-hand side must be compatible with the field. So +``price < '3'`` (while ``'3'`` is still numeric) would result in a compiler +error. + +.. list-table:: + :header-rows: 1 + + * - Expression + - SQL + - Description + + * - + .. code-block:: sql + + field == value + + - + .. code-block:: sql + + field = value + + - Equal. + + * - + .. code-block:: sql + + field != value + + - + .. code-block:: sql + + field <> value + + - Not equal. + + * - + .. code-block:: sql + + field > value + + - + .. code-block:: sql + + field > value + + - Greater than. + + * - + .. code-block:: sql + + field >= value + + - + .. code-block:: sql + + field >= value + + - Greater than or equal. + + * - + .. code-block:: sql + + field < value + + - + .. code-block:: sql + + field < value + + - Less than. + + * - + .. code-block:: sql + + field <= value + + - + .. code-block:: sql + + field <= value + + - Less than or equal. + + * - + .. code-block:: sql + + field like 'value' + + - + .. code-block:: sql + + field LIKE 'value' + + - Basic regular expressions. These are case-sensitive. + + * - + .. code-block:: sql + + field is none + + - + .. code-block:: sql + + field IS NULL + + - Check for NULL. This is not the same as an empty value. + + * - + .. code-block:: sql + + field !is none + + - + .. code-block:: sql + + field IS NOT NULL + + - Check for NOT NULL. This is not the same as a non-empty value. diff --git a/docs/testing.rst b/docs/testing.rst index 0cc47db..454e480 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -141,6 +141,16 @@ Connection The connection test suite is responsible for testing that various operations from concurrent connections do not cause race conditions and other anomalies. +ORM +^^^ + +The ORM suite is for testing `V's ORM `_ and +can be found in +`orm_test.v `. + +The ORM test suite is run as part of the main ``make test`` but you can run it +specifically with ``make orm-test``. + SQL ^^^ diff --git a/docs/v-client-library-docs.rst b/docs/v-client-library-docs.rst index d4265f4..54cdce4 100644 --- a/docs/v-client-library-docs.rst +++ b/docs/v-client-library-docs.rst @@ -13,15 +13,29 @@ Constants -fn open -------- +fn open_database +---------------- .. code-block:: v - pub fn open(path string) !&Connection + pub fn open_database(path string, options ConnectionOptions) !&Connection -open is the convenience function for open_database() with default options. +open_database will open an existing database file or create a new file if the path does not exist. + +If the file does exist, open_database will assume that the file is a valid database file (not corrupt). Otherwise unexpected behavior or even a crash may occur. + +The special file name ":memory:" can be used to create an entirely in-memory database. This will be faster but all data will be lost when the connection is closed. + +open_database can be used concurrently for reading and writing to the same file and provides the following default protections: + +- Fine: Multiple processes open_database() the same file. + +- Fine: Multiple goroutines sharing an open_database() on the same file. + +- Bad: Multiple goroutines open_database() the same file. + +See ConnectionOptions and default_connection_options(). fn catalog_name_from_path ------------------------- @@ -85,29 +99,15 @@ fn default_connection_options default_connection_options returns the sensible defaults used by open() and the correct base to provide your own option overrides. See ConnectionOptions. -fn open_database ----------------- +fn open_orm +----------- .. code-block:: v - pub fn open_database(path string, options ConnectionOptions) !&Connection + pub fn open_orm(path string) !ORMConnection -open_database will open an existing database file or create a new file if the path does not exist. -If the file does exist, open_database will assume that the file is a valid database file (not corrupt). Otherwise unexpected behavior or even a crash may occur. - -The special file name ":memory:" can be used to create an entirely in-memory database. This will be faster but all data will be lost when the connection is closed. - -open_database can be used concurrently for reading and writing to the same file and provides the following default protections: - -- Fine: Multiple processes open_database() the same file. - -- Fine: Multiple goroutines sharing an open_database() on the same file. - -- Bad: Multiple goroutines open_database() the same file. - -See ConnectionOptions and default_connection_options(). fn new_benchmark ---------------- @@ -287,25 +287,35 @@ fn new_timestamp_value new_timestamp_value creates a ``TIMESTAMP`` value. -fn new_varchar_value +fn new_unknown_value -------------------- .. code-block:: v - pub fn new_varchar_value(x string) Value + pub fn new_unknown_value() Value -new_varchar_value creates a ``CHARACTER VARYING`` value. +new_unknown_value returns an ``UNKNOWN`` value. This is the ``NULL`` representation of ``BOOLEAN``. -fn new_unknown_value +fn open +------- + + +.. code-block:: v + + pub fn open(path string) !&Connection + +open is the convenience function for open_database() with default options. + +fn new_varchar_value -------------------- .. code-block:: v - pub fn new_unknown_value() Value + pub fn new_varchar_value(x string) Value -new_unknown_value returns an ``UNKNOWN`` value. This is the ``NULL`` representation of ``BOOLEAN``. +new_varchar_value creates a ``CHARACTER VARYING`` value. type Row -------- @@ -341,6 +351,37 @@ enum Boolean Possible values for a BOOLEAN. +struct PageObject +----------------- + + +.. code-block:: v + + pub struct PageObject { + // The key is not required to be unique in the page. It becomes unique when + // combined with tid. However, no more than two version of the same key can + // exist in a page. See the caveats at the top of btree.v. + key []u8 + // The value contains the serialized data for the object. The first byte of + // key is used to both identify what type of object this is and also keep + // objects within the same collection also within the same range. + value []u8 + // When is_blob_ref is true, the value will be always be 5 bytes. See + // blob_info(). + is_blob_ref bool + mut: + // The tid is the transaction that created the object. + // + // TODO(elliotchance): It makes more sense to construct a new PageObject + // when changing the tid and xid. + tid int + // The xid is the transaciton that deleted the object, or zero if it has + // never been deleted. + xid int + } + +TODO(elliotchance): This does not need to be public. It was required for a bug at the time with V not being able to pass this to the shuffle function. At some point in the future remove the pub and see if it works. + struct ConnectionOptions ------------------------ @@ -684,36 +725,18 @@ struct Value A single value. It contains it's type information in ``typ``. -struct PageObject ------------------ +struct ORMConnection +-------------------- .. code-block:: v - pub struct PageObject { - // The key is not required to be unique in the page. It becomes unique when - // combined with tid. However, no more than two version of the same key can - // exist in a page. See the caveats at the top of btree.v. - key []u8 - // The value contains the serialized data for the object. The first byte of - // key is used to both identify what type of object this is and also keep - // objects within the same collection also within the same range. - value []u8 - // When is_blob_ref is true, the value will be always be 5 bytes. See - // blob_info(). - is_blob_ref bool + pub struct ORMConnection { mut: - // The tid is the transaction that created the object. - // - // TODO(elliotchance): It makes more sense to construct a new PageObject - // when changing the tid and xid. - tid int - // The xid is the transaciton that deleted the object, or zero if it has - // never been deleted. - xid int + c Connection } -TODO(elliotchance): This does not need to be public. It was required for a bug at the time with V not being able to pass this to the shuffle function. At some point in the future remove the pub and see if it works. + struct Identifier ----------------- diff --git a/examples/orm.v b/examples/orm.v new file mode 100644 index 0000000..9edc56a --- /dev/null +++ b/examples/orm.v @@ -0,0 +1,43 @@ +import os +import vsql + +fn main() { + os.rm('test.vsql') or {} + example() or { panic(err) } +} + +struct Product { + id int @[primary] + product_name string @[sql_type: 'varchar(100)'] + price f64 +} + +fn (p Product) str() string { + return '${p.product_name} ($${p.price})' +} + +fn example() ! { + mut db := vsql.open_orm('test.vsql')! + + sql db { + create table Product + }! + + products := [ + Product{1, 'Ice Cream', 5.99}, + Product{2, 'Ham Sandwhich', 3.47}, + Product{3, 'Bagel', 1.25}, + ] + for product in products { + sql db { + insert product into Product + }! + } + + println('Products over $2:') + for row in sql db { + select from Product where price > 2 + }! { + println(row) + } +} diff --git a/vsql/connection.v b/vsql/connection.v index 858e3aa..a7429fd 100644 --- a/vsql/connection.v +++ b/vsql/connection.v @@ -67,6 +67,10 @@ pub fn open(path string) !&Connection { return open_database(path, default_connection_options()) } +pub fn open_orm(path string) !ORMConnection { + return ORMConnection{open(path)!} +} + // open_database will open an existing database file or create a new file if the // path does not exist. // @@ -412,6 +416,23 @@ pub fn (mut conn CatalogConnection) schema_tables(schema string) ![]Table { return tables } +// schema_table returns the table for the provided schema. If the schema or +// table does not exist and empty list will be returned. +pub fn (mut conn CatalogConnection) schema_table(schema string, table string) !Table { + conn.open_read_connection()! + defer { + conn.release_read_connection() + } + + for _, t in conn.storage.tables { + if t.name.schema_name == schema && t.name.entity_name == table { + return t + } + } + + return sqlstate_42p01('table', table) // table does not exist +} + // resolve_identifier returns a new identifier that would represent the // canonical (fully qualified) form. fn (conn Connection) resolve_identifier(identifier Identifier) Identifier { diff --git a/vsql/earley.v b/vsql/earley.v index 47ed72b..44fd90f 100644 --- a/vsql/earley.v +++ b/vsql/earley.v @@ -237,7 +237,8 @@ fn parse(tokens []Token) !Stmt { mut columns := tokenize_earley_columns(tokens) mut grammar := get_grammar() - q0 := parse_earley(grammar[''], mut columns)! + q0 := parse_earley(grammar[''] or { panic('no entry rule') }, mut + columns)! trees := build_trees(q0) if trees.len == 0 { diff --git a/vsql/orm.v b/vsql/orm.v new file mode 100644 index 0000000..0a6882b --- /dev/null +++ b/vsql/orm.v @@ -0,0 +1,405 @@ +// orm.v implements the V language ORM: https://modules.vlang.io/orm.html + +module vsql + +import orm +import time + +pub struct ORMConnection { +mut: + c Connection +} + +fn (c ORMConnection) connection() Connection { + return c.c +} + +pub fn (c ORMConnection) @select(config orm.SelectConfig, data orm.QueryData, where orm.QueryData) ![][]orm.Primitive { + mut stmt := orm.orm_select_gen(config, '', true, ':p', 0, where) + + // TODO(elliotchance): This is a (crude) work around until + // https://github.com/vlang/v/pull/20321 is fixed. + stmt = stmt.replace(' != ', ' <> ') + + mut catalog := unsafe { c.c.catalog() } + table_definition := catalog.schema_table('PUBLIC', orm_table_name(config.table))! + + mut bound := map[string]Value{} + for i, name in where.fields { + // TODO(elliotchance): IS NULL does not have a data counterpart. + if i < where.data.len { + bound['p${i}'] = primitive_to_value(table_definition.column(name.to_upper())!.typ, + where.data[i])! + } + } + + mut prepared := unsafe { c.c.prepare(stmt)! } + result := prepared.query(bound)! + mut all_rows := [][]orm.Primitive{} + + for row in result { + mut primitive_row := []orm.Primitive{} + for column in result.columns { + primitive_row << row.get_primitive(column.name.str())! + } + + all_rows << primitive_row + } + + return all_rows +} + +pub fn (c ORMConnection) insert(table string, data orm.QueryData) ! { + mut values := data.data.map(fn (p orm.Primitive) string { + match p { + orm.InfixType { + // TODO(elliotchance): Not sure what this is? + return '${p}' + } + time.Time { + // TODO(elliotchance): This doesn't work. + return '${p}' + } + orm.Null { + return 'NULL' + } + bool { + if p { + return 'TRUE' + } + + return 'FALSE' + } + string { + // TODO(elliotchance): Does not escape correctly. + return '\'${p}\'' + } + f32 { + return '${p}' + } + f64 { + return '${p}' + } + i16 { + return '${p}' + } + i64 { + return '${p}' + } + i8 { + return '${p}' + } + int { + return '${p}' + } + u16 { + return '${p}' + } + u32 { + return '${p}' + } + u64 { + return '${p}' + } + u8 { + return '${p}' + } + } + }) + + // Substitute SERIAL/AUTO. + if data.auto_fields.len > 1 { + return error('multiple AUTO fields are not supported') + } else if data.auto_fields.len == 1 { + values[data.auto_fields[0]] = 'NEXT VALUE FOR "${serial_name(table)}"' + } + + insert_sql := 'INSERT INTO ${table} (${data.fields.join(', ')}) VALUES (${values.join(', ')})' + c.execute(insert_sql)! +} + +fn extract_bound_params(table_definition Table, offset int, data orm.QueryData) !map[string]Value { + mut bound := map[string]Value{} + for i, name in data.fields { + // TODO(elliotchance): IS NULL does not have a data counterpart. + if i < data.data.len { + bound['p${i + offset}'] = primitive_to_value(table_definition.column(name.to_upper())!.typ, + data.data[i])! + } + } + + return bound +} + +pub fn (c ORMConnection) update(table string, data orm.QueryData, where orm.QueryData) ! { + stmt, _ := orm.orm_stmt_gen(.default, table, '', .update, true, ':p', 0, data, where) + mut catalog := unsafe { c.c.catalog() } + table_definition := catalog.schema_table('PUBLIC', orm_table_name(table))! + mut bound := extract_bound_params(table_definition, 0, data)! + for k, v in extract_bound_params(table_definition, bound.len, where)! { + bound[k] = v + } + + mut prepared := unsafe { c.c.prepare(stmt)! } + prepared.query(bound)! +} + +pub fn (c ORMConnection) delete(table string, where orm.QueryData) ! { + stmt, _ := orm.orm_stmt_gen(.default, table, '', .delete, true, ':p', 0, orm.QueryData{}, + where) + mut catalog := unsafe { c.c.catalog() } + table_definition := catalog.schema_table('PUBLIC', orm_table_name(table))! + bound := extract_bound_params(table_definition, 0, where)! + + mut prepared := unsafe { c.c.prepare(stmt)! } + prepared.query(bound)! +} + +fn serial_name(table string) string { + return '${table}_SERIAL' +} + +fn (c ORMConnection) execute(stmt string) ! { + unsafe { + c.c.query(stmt) or { return error('${err}: ${stmt}') } + } +} + +pub fn (c ORMConnection) create(table string, fields []orm.TableField) ! { + mut sql_fields := []string{} + mut primary_key := '' + + for field in fields { + mut typ := orm_type_to_sql(field.name, field.typ, field.nullable)! + + // The double quotes and uppercase are required to make sure that reserved + // words are possible. + mut column_name := "\"${field.name.to_upper()}\"" + + for attr in field.attrs { + match attr.name { + 'sql' { + // "sql" is used to overload a bunch of different things. + if attr.arg == 'serial' { + c.execute('CREATE SEQUENCE "${serial_name(table)}"')! + primary_key = column_name + } else if orm.type_idx[attr.arg] != 0 { + typ = orm_type_to_sql(field.name, orm.type_idx[attr.arg], field.nullable)! + } else { + // Unlike above, we do not convert this to uppercase because we do + // not have the same V language naming limitations in the attribute. + // This means that in almost all cases you want the custom name to + // be in UPPERCASE if you want to use mixed cases in queries. + column_name = "\"${attr.arg}\"" + } + } + 'sql_type' { + typ = attr.arg + if field.nullable { '' } else { ' NOT NULL' } + } + 'primary' { + primary_key = column_name + } + 'unique' { + // Unique is not supported yet. It's better to throw an error so the + // data stays consistent. + return error('for ${field.name}: UNIQUE is not supported') + } + 'default' { + return error('for ${field.name}: DEFAULT is not supported') + } + else {} + } + } + + sql_fields << '${column_name} ${typ}' + } + + if primary_key != '' { + sql_fields << 'PRIMARY KEY (${primary_key})' + } + + table_name := orm_table_name(table) + + create_table_sql := 'CREATE TABLE ${table_name} (${sql_fields.join(', ')})' + c.execute(create_table_sql)! +} + +fn orm_table_name(table string) string { + // It's not possible to know if the table name has been generated from the + // struct name or extracted from @[table]. This creates a problem because we + // need to uppercase all generated names so they are safe to double-quote. + // However, this means that @[table] can never explicitly specify a case + // sensitive table name. The way around this is to require the table name to + // already be quoted if that was in the intention from @[table]. + if table[0] == `"` { + return table + } + + return requote_identifier(table.to_upper()) +} + +fn orm_type_to_sql(field_name string, typ int, nullable bool) !string { + mut base_type := '' + + match typ { + orm.serial { + // This comes from @[sql: serial] which is supported, but it shouldn't + // come through this function as it's handled separately. Although if it + // does and we do need a type INTEGER should be a sensible default. + // + // NOT NULL is implied because this has to be part of the PRIMARY KEY. + return 'INTEGER NOT NULL' + } + orm.enum_ { + return error('for ${field_name}: ENUM is not supported') + } + orm.time_ { + // Let's choose the highest precision time possible (microseconds). + base_type = 'TIMESTAMP(6) WITH TIME ZONE' + } + orm.type_idx['i8'], orm.type_idx['u8'], orm.type_idx['i16'] { + base_type = 'SMALLINT' + } + orm.type_idx['u16'], orm.type_idx['int'] { + base_type = 'INTEGER' + } + orm.type_idx['i64'], orm.type_idx['u32'] { + base_type = 'BIGINT' + } + orm.type_idx['u64'] { + // u64 will not fit into a BIGINT so we have to use a larger exact type. + // It's worth noting that the database will allow a greater range than + // u64 (including negatives) but unless you're constructing SQL manually + // the u64 type will prevent you from doing this. + base_type = 'NUMERIC(20)' + } + orm.type_idx['f32'] { + base_type = 'REAL' + } + orm.type_idx['f64'] { + base_type = 'DOUBLE PRECISION' + } + orm.type_idx['bool'] { + base_type = 'BOOLEAN' + } + orm.type_idx['string'] { + base_type = 'VARCHAR(${orm.string_max_len})' + } + else { + // V's Type is an alias for int so we must include an else clause. There + // are also types that are not included in this switch that do not + // apply, or otherwise have no corresponding SQL type. + return error('unsupported type for ${field_name}: ${typ}') + } + } + + if !nullable { + return base_type + ' NOT NULL' + } + + return base_type +} + +pub fn (c ORMConnection) drop(table string) ! { + c.execute('DROP TABLE ${orm_table_name(table)}')! +} + +pub fn (c ORMConnection) last_id() int { + // TODO(elliotchance): This is not implemented yet. + // + // There is no SQL statement for extracting the current SEQUENCE value so we + // have to refactor insert() to call NEXT VALUE before the execution. + return 0 +} + +// primitive_to_value returns the Value of a Primitive based on the intended +// destination type. Primitives are used by the ORM. +// +// It's important to note that while types may be compatible, they can still be +// out of range, such as assigning an overflowing integer value to SMALLINT. +fn primitive_to_value(typ Type, p orm.Primitive) !Value { + // The match should be exhaustive for typ and p so that we can make sure we + // cover all combinations now and in the future. + match p { + orm.Null { + // In standard SQL, NULL's must be typed. + return new_null_value(typ.typ) + } + bool { + match typ.typ { + .is_boolean { + return new_boolean_value(p) + } + else {} + } + } + f32, f64 { + match typ.typ { + .is_real { + return new_real_value(f32(p)) + } + .is_double_precision { + return new_double_precision_value(f64(p)) + } + else {} + } + } + i16, i8, u8 { + match typ.typ { + .is_smallint { + return new_smallint_value(i16(p)) + } + else {} + } + } + int, u16 { + match typ.typ { + .is_smallint { + return new_smallint_value(i16(p)) + } + .is_integer { + return new_integer_value(int(p)) + } + else {} + } + } + u32, i64 { + match typ.typ { + .is_bigint { + return new_bigint_value(i64(p)) + } + else {} + } + } + u64 { + match typ.typ { + .is_smallint { + return new_smallint_value(i16(p)) + } + else {} + } + } + string { + match typ.typ { + .is_varchar { + return new_varchar_value(p) + } + .is_numeric { + return new_numeric_value(p) + } + else {} + } + } + time.Time { + match typ.typ { + .is_timestamp_with_time_zone, .is_timestamp_without_time_zone { + return new_timestamp_value(p.str())! + } + else {} + } + } + orm.InfixType {} + } + + return error('cannot assign ${p} to ${typ}') +} diff --git a/vsql/orm_test.v b/vsql/orm_test.v new file mode 100644 index 0000000..7ab8619 --- /dev/null +++ b/vsql/orm_test.v @@ -0,0 +1,370 @@ +module vsql + +import os +import time + +// ORMTable1 is for testing CREATE TABLE, it has many combinations of types and +// other attributes that are verified from the generated CREATE TABLE +// afterwards. +struct ORMTable1 { + // Each of the basic orm.Primative types without specifying any options. + a_bool bool // BOOLEAN + an_f32 f32 // REAL + an_f64 f64 // DOUBLE PRECISION + an_i16 i16 // SMALLINT + an_i64 i64 // BIGINT + an_i8 i8 // SMALLINT + an_int int // INTEGER + a_string string // CHARACTER VARYING(255) + a_time time.Time // TIMESTAMP(6) WITH TIME ZONE + an_u16 u16 // INTEGER + an_u32 u32 // BIGINT + an_u64 u64 // NUMERIC(20) + an_u8 u8 // SMALLINT + // Naming edge cases + where int // reserved word + actual_name int @[sql: 'secret_name'] + order int @[sql: 'ORDER'] + // Primary keys and other indexes. + a_primary_key int @[primary] // PRIMARY KEY (A_PRIMARY_KEY) + // Skipped fields + this_is_skipped int @[skip] + this_as_well int @[sql: '-'] + // Customize types + not_an_int int @[sql: string] + custom_sql_type int @[sql_type: 'NUMERIC(10)'] + // Nullable types + int_or_null ?int +} + +fn test_orm_create_success1() { + mut db := new_db() + sql db { + create table ORMTable1 + }! + + mut c := db.connection() + mut catalog := c.catalog() + mut table := catalog.schema_table('PUBLIC', 'ORMTABLE1')! + assert table.str() == 'CREATE TABLE "test".PUBLIC.ORMTABLE1 ( + A_BOOL BOOLEAN NOT NULL, + AN_F32 REAL NOT NULL, + AN_F64 DOUBLE PRECISION NOT NULL, + AN_I16 SMALLINT NOT NULL, + AN_I64 BIGINT NOT NULL, + AN_I8 SMALLINT NOT NULL, + AN_INT INTEGER NOT NULL, + A_STRING CHARACTER VARYING(2048) NOT NULL, + A_TIME TIMESTAMP(6) WITH TIME ZONE NOT NULL, + AN_U16 INTEGER NOT NULL, + AN_U32 BIGINT NOT NULL, + AN_U64 NUMERIC(20) NOT NULL, + AN_U8 SMALLINT NOT NULL, + "WHERE" INTEGER NOT NULL, + "secret_name" INTEGER NOT NULL, + "ORDER" INTEGER NOT NULL, + A_PRIMARY_KEY INTEGER NOT NULL, + NOT_AN_INT CHARACTER VARYING(2048) NOT NULL, + CUSTOM_SQL_TYPE NUMERIC(10) NOT NULL, + INT_OR_NULL INTEGER, + PRIMARY KEY (A_PRIMARY_KEY) +);' +} + +// ORMTable2 tests some cases that are not possible on ORMTable1. Specifically: +// - A custom table name (that uses a reserved word to check quoting) +// - No primary key. +@[table: 'GROUP'] +struct ORMTable2 { + dummy int +} + +fn test_orm_create_success2() { + mut db := new_db() + sql db { + create table ORMTable2 + }! + + mut c := db.connection() + mut catalog := c.catalog() + assert catalog.schema_table('PUBLIC', 'GROUP')!.str() == 'CREATE TABLE "test".PUBLIC."GROUP" ( + DUMMY INTEGER NOT NULL +);' +} + +// ORMTableUnique makes sure we throw an error if @[unique] is used since it's +// not supported. +struct ORMTableUnique { + is_unique int @[unique] +} + +fn test_orm_create_unique_is_not_supported() { + mut db := new_db() + mut error := '' + sql db { + create table ORMTableUnique + } or { error = err.str() } + assert error == 'for is_unique: UNIQUE is not supported' +} + +// ORMTableSpecificName covers the edge case where already quoted table names +// need to remain intact. This is explained in more detail in create(). +// +// The extra escape is required for now, see bug +// https://github.com/vlang/v/issues/20313. +@[table: '\"specific name\"'] +struct ORMTableSpecificName { + dummy int +} + +fn test_orm_create_specific_name() { + mut db := new_db() + sql db { + create table ORMTableSpecificName + }! + + mut c := db.connection() + mut catalog := c.catalog() + assert catalog.schema_table('PUBLIC', 'specific name')!.str() == 'CREATE TABLE "test".PUBLIC."specific name" ( + DUMMY INTEGER NOT NULL +);' +} + +// ORMTableSerial lets the DB backend choose a column type for a auto-increment +// field. +struct ORMTableSerial { + dummy int @[sql: serial] +} + +fn test_orm_create_serial() { + mut db := new_db() + sql db { + create table ORMTableSerial + }! + + mut c := db.connection() + mut catalog := c.catalog() + assert catalog.schema_table('PUBLIC', 'ORMTABLESERIAL')!.str() == 'CREATE TABLE "test".PUBLIC.ORMTABLESERIAL ( + DUMMY INTEGER NOT NULL, + PRIMARY KEY (DUMMY) +);' +} + +// ORMTableEnum is not supported. +struct ORMTableEnum { + an_enum Colors +} + +enum Colors { + red + green + blue +} + +fn test_orm_create_enum_is_not_supported() { + mut db := new_db() + mut error := '' + sql db { + create table ORMTableEnum + } or { error = err.str() } + assert error == 'for an_enum: ENUM is not supported' +} + +// ORMTableDefault is not supported. +struct ORMTableDefault { + has_default int @[default: '3'] +} + +fn test_orm_create_default_is_not_supported() { + mut db := new_db() + mut error := '' + sql db { + create table ORMTableDefault + } or { error = err.str() } + assert error == 'for has_default: DEFAULT is not supported' +} + +struct Product { + id int // @[primary] FIXME + product_name string + price string @[sql_type: 'NUMERIC(5,2)'] + quantity ?i16 +} + +fn test_orm_insert() { + mut db := new_db() + sql db { + create table Product + }! + + product := Product{1, 'Ice Cream', '5.99', 17} + sql db { + insert product into Product + }! + + mut rows := []Product{} + for row in sql db { + select from Product + }! { + rows << row + } + + assert rows == [product] +} + +fn test_orm_update() { + mut db := new_db_with_products() + + sql db { + update Product set quantity = 16 where product_name == 'Ice Cream' + }! + + assert sql db { + select from Product + }! == [ + Product{1, 'Ice Cream', '5.99', 16}, // 17 -> 16 + Product{2, 'Ham Sandwhich', '3.47', none}, + Product{3, 'Bagel', '1.25', 45}, + ] +} + +fn test_orm_delete() { + mut db := new_db_with_products() + + sql db { + delete from Product where product_name == 'Ice Cream' + }! + + assert sql db { + select from Product + }! == [ + Product{2, 'Ham Sandwhich', '3.47', none}, + Product{3, 'Bagel', '1.25', 45}, + ] +} + +struct PrimaryColor { + id int @[sql: serial] + name string +} + +fn test_orm_insert_serial() { + mut db := new_db() + sql db { + create table PrimaryColor + }! + + colors := [PrimaryColor{ + name: 'red' + }, PrimaryColor{ + name: 'green' + }, PrimaryColor{ + name: 'blue' + }] + for color in colors { + sql db { + insert color into PrimaryColor + }! + } + + mut rows := []PrimaryColor{} + for row in sql db { + select from PrimaryColor + }! { + rows << row + } + + assert rows == [PrimaryColor{1, 'red'}, PrimaryColor{2, 'green'}, + PrimaryColor{3, 'blue'}] +} + +@[assert_continues] +fn test_orm_select_where() { + db := new_db_with_products() + + assert sql db { + select from Product where id == 2 + }! == [Product{2, 'Ham Sandwhich', '3.47', none}] + + assert sql db { + select from Product where id == 5 + }! == [] + + assert sql db { + select from Product where id != 3 + }! == [Product{1, 'Ice Cream', '5.99', 17}, Product{2, 'Ham Sandwhich', '3.47', none}] + + assert sql db { + select from Product where price > '3.47' + }! == [Product{1, 'Ice Cream', '5.99', 17}] + + assert sql db { + select from Product where price >= '3' + }! == [Product{1, 'Ice Cream', '5.99', 17}, Product{2, 'Ham Sandwhich', '3.47', none}] + + assert sql db { + select from Product where price < '3.47' + }! == [Product{3, 'Bagel', '1.25', 45}] + + assert sql db { + select from Product where price <= '5' + }! == [Product{2, 'Ham Sandwhich', '3.47', none}, Product{3, 'Bagel', '1.25', 45}] + + // TODO(elliotchance): The ORM does not support a "not like" constraint right + // now. + // assert sql db { + // select from Product where product_name !like 'Ham%' + // }! == [Product{2, 'Ham Sandwhich', '3.47', none}] + + assert sql db { + select from Product where quantity is none + }! == [Product{2, 'Ham Sandwhich', '3.47', none}] + + assert sql db { + select from Product where quantity !is none + }! == [Product{1, 'Ice Cream', '5.99', 17}, Product{3, 'Bagel', '1.25', 45}] + + assert sql db { + select from Product where price > '3' && price < '3.50' + }! == [Product{2, 'Ham Sandwhich', '3.47', none}] + + assert sql db { + select from Product where price < '2.000' || price >= '5' + }! == [Product{1, 'Ice Cream', '5.99', 17}, Product{3, 'Bagel', '1.25', 45}] +} + +fn new_db() ORMConnection { + os.rm('test.vsql') or {} + return open_orm('test.vsql') or { panic(err) } +} + +fn new_db_with_products() ORMConnection { + mut db := new_db() + sql db { + create table Product + } or { panic(err) } + + products := [ + Product{1, 'Ice Cream', '5.99', 17}, + Product{2, 'Ham Sandwhich', '3.47', none}, + Product{3, 'Bagel', '1.25', 45}, + ] + for product in products { + sql db { + insert product into Product + } or { panic(err) } + } + + return db +} + +fn test_orm_drop_table() { + mut db := new_db() + sql db { + create table Product + }! + + sql db { + drop table Product + }! +} diff --git a/vsql/row.v b/vsql/row.v index db37c30..61ea8cd 100644 --- a/vsql/row.v +++ b/vsql/row.v @@ -4,6 +4,7 @@ module vsql import time +import orm // Represents a single row which may contain one or more columns. struct Row { @@ -105,6 +106,13 @@ fn (r Row) for_storage() Row { return Row{r.id, r.tid, new_data} } +// primitives are used by the ORM. +pub fn (r Row) get_primitive(name string) !orm.Primitive { + value := r.get(name)! + + return value.primitive() +} + // new_empty_row is used internally to generate a row with zero values for all // the types in a Row. This is used for testing expressions without needing the // actual row. diff --git a/vsql/std_names_and_identifiers.v b/vsql/std_names_and_identifiers.v index 9ec7f3b..fd49ae8 100644 --- a/vsql/std_names_and_identifiers.v +++ b/vsql/std_names_and_identifiers.v @@ -275,7 +275,7 @@ fn split_identifier_parts(s string) ![]string { } fn requote_identifier(s string) string { - if s.to_upper() == s { + if s.to_upper() == s && !is_reserved_word(s) { return s } diff --git a/vsql/table.v b/vsql/table.v index 6fda268..d0f34d0 100644 --- a/vsql/table.v +++ b/vsql/table.v @@ -19,7 +19,11 @@ pub: // "foo" INT // BAR DOUBLE PRECISION NOT NULL pub fn (c Column) str() string { - mut f := '${c.name} ${c.typ}' + return '${c.name} ${c.type_str()}' +} + +fn (c Column) type_str() string { + mut f := c.typ.str() if c.not_null { f += ' NOT NULL' } @@ -133,7 +137,11 @@ pub fn (t Table) str() string { mut cols := []string{} for col in t.columns { - cols << ' ${col}' + cols << ' ${requote_identifier(col.name.sub_entity_name)} ${col.type_str()}' + } + + if t.primary_key.len > 0 { + cols << ' PRIMARY KEY (${t.primary_key.map(requote_identifier).join(', ')})' } return s + '\n' + cols.join(',\n') + '\n);' diff --git a/vsql/value.v b/vsql/value.v index 0295d9f..75f80b0 100644 --- a/vsql/value.v +++ b/vsql/value.v @@ -5,6 +5,7 @@ module vsql import regex +import orm // Possible values for a BOOLEAN. pub enum Boolean { @@ -186,6 +187,15 @@ pub fn new_numeric_value(x string) Value { } } +fn bool_str(x f64) string { + return match x { + 0 { 'FALSE' } + 1 { 'TRUE' } + 2 { 'UNKNOWN' } + else { 'NULL' } + } +} + // new_decimal_value expects a value to be valid and the size and scale are // determined from the value as: // @@ -430,3 +440,41 @@ pub fn (v Value) numeric_value() Numeric { return v.v.numeric_value } } + +// primitives are used by the ORM. +fn (v Value) primitive() !orm.Primitive { + if v.is_null { + return orm.Null{} + } + + return match v.typ.typ { + .is_boolean { + orm.Primitive(v.bool_value() == .is_true) + } + .is_smallint { + orm.Primitive(i16(v.int_value())) + } + .is_integer { + orm.Primitive(int(v.int_value())) + } + .is_bigint { + orm.Primitive(i64(v.int_value())) + } + .is_varchar, .is_character { + orm.Primitive(v.string_value()) + } + .is_real { + orm.Primitive(f32(v.f64_value())) + } + .is_double_precision { + orm.Primitive(v.f64_value()) + } + .is_decimal, .is_numeric { + orm.Primitive(v.str()) + } + .is_date, .is_time_with_time_zone, .is_timestamp_without_time_zone, + .is_timestamp_with_time_zone, .is_time_without_time_zone { + orm.Primitive(v.str()) + } + } +}