diff --git a/Makefile b/Makefile index 8266914..41cbd40 100644 --- a/Makefile +++ b/Makefile @@ -86,6 +86,9 @@ btree-test: oldv sql-test: oldv $(V) -stats $(BUILD_OPTIONS) vsql/sql_test.v +orm-test: oldv + $(V) -stats $(BUILD_OPTIONS) vsql/orm_test.v + # CLI Tests cli-test: bin/vsql diff --git a/examples/orm.v b/examples/orm.v new file mode 100644 index 0000000..91d4f66 --- /dev/null +++ b/examples/orm.v @@ -0,0 +1,61 @@ +import os +import vsql +import time + +// CHECK ./vsql/orm_test.v for more advanced usage + +fn main() { + os.rm('test.vsql') or {} + + example() or { panic(err) } +} + +// NOTE for some reason if we declare a @[primary] on a struct field, we can not do delete queries on the tables... +// https://github.com/elliotchance/vsql/issues/200 +struct Product { + id int //@[primary] + product_name string @[sql_type: 'varchar(100)'] + price f64 +} + +fn example() ! { + timer := time.new_stopwatch() + mut db := vsql.open('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 + } or { panic(err) } + } + sql db { + update Product set product_name = 'Cereal' where id == 1 + } or { panic(err) } + + prod_one := sql db { + select from Product where id == 1 + }! + + assert prod_one.len == 1 + + sql db { + delete from Product where product_name == 'Cereal' + } or { panic(err) } + + all := sql db { + select from Product + }! + + assert all.len == 2 + + println(timer.elapsed()) +} diff --git a/vsql/orm.v b/vsql/orm.v new file mode 100644 index 0000000..9a76e54 --- /dev/null +++ b/vsql/orm.v @@ -0,0 +1,371 @@ +module vsql + +import orm +import time + +pub const varchar_default_len = 255 + +// Returns a string of the vsql type based on the v type index +fn vsql_type_from_v(typ int) !string { + return if typ == orm.type_idx['i8'] || typ == orm.type_idx['i16'] || typ == orm.type_idx['u8'] { + 'SMALLINT' + } else if typ == orm.type_idx['bool'] { + 'BOOLEAN' + } else if typ == orm.type_idx['int'] || typ == orm.type_idx['u16'] || typ == 8 { + 'INTEGER' + } else if typ == orm.type_idx['i64'] || typ == orm.type_idx['u32'] { + 'BIGINT' + } else if typ == orm.type_idx['f32'] { + 'REAL' + } else if typ == orm.type_idx['u64'] { + 'BIGINT' + } else if typ == orm.type_idx['f64'] { + 'DOUBLE PRECISION' + } else if typ == orm.type_idx['string'] { + 'VARCHAR(${varchar_default_len})' + } else if typ == orm.time_ { + 'TIMESTAMP(6) WITHOUT TIME ZONE' + } else if typ == -1 { + // -1 is a field with @[sql: serial] + 'INTEGER' + } else { + error('Unknown type ${typ}') + } +} + +// 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)) + } + .is_bigint { + return new_bigint_value(i64(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}') +} + +fn serial_name(table string) string { + return '${table}_SERIAL' +} + +fn reformat_table_name(table string) string { + mut new_table := table + if is_reserved_word(table) { + new_table = '"${table}"' + } + return new_table +} + +fn get_table_values_map(mut db Connection, table string, data []orm.QueryData) !map[string]Value { + mut mp := map[string]Value{} + mut tbl := Table{} + mut cat := db.catalog() + tables := cat.schema_tables('PUBLIC') or { [] } + + for t in tables { + if t.name.entity_name == table.to_upper() { + tbl = t + break + } + } + if tbl.name.entity_name != table.to_upper() { + return error('Table ${table} not found') + } + mut field_counter := 1 + for d in data { + for i, f in d.fields { + for c in tbl.columns { + if c.name.sub_entity_name == f.to_upper() { + if mp.keys().contains(f) { + field_key := '${f}_${field_counter.str()}' + mp[field_key] = primitive_to_value(c.typ, d.data[i])! + field_counter++ + } else { + mp[f] = primitive_to_value(c.typ, d.data[i])! + } + } + } + } + } + + return mp +} + +// `query_reformatter` converts a statement like `INSERT INTO Product (id, product_name, price) VALUES (:1, :2, :3)` to `INSERT INTO Product (id, product_name, price) VALUES (:id, :product_name, :price)` +fn query_reformatter(query string, query_data []orm.QueryData) string { + mut counter := 1 + mut field_counter := 1 + mut new_query := query + for data in query_data { + for field in data.fields { + // this if check is for if there are multiple of the same field being checked for + if new_query.contains(':${field}') { + new_query = new_query.replace(':${counter}', ':${field}_${field_counter.str()}') + field_counter++ + counter++ + } else { + new_query = new_query.replace(':${counter}', ':${field}') + counter++ + } + } + } + return new_query +} + +fn check_for_not_supported(mut db Connection, table string, fields []orm.TableField) ! { + for field in fields { + if field.typ == orm.enum_ { + return error('enum is not supported in vsql') + } + if is_reserved_word(field.name) { + return error('reserved word ${field.name} cannot be used as a field name at ${table}.${field.name}') + } + for attr in field.attrs { + if attr.name == 'sql' { + if attr.arg == 'serial' { + db.query('CREATE SEQUENCE "${serial_name(table)}"')! + } + if is_reserved_word(attr.arg) { + return error('${attr.arg} is a reserved word in vsql') + } + } + if attr.name == 'default' { + return error('default is not supported in vsql') + } + if attr.name == 'unique' { + return error('unique is not supported in vsql') + } + } + } +} + +// select is used internally by V's ORM for processing `SELECT` queries +pub fn (mut db Connection) select(config orm.SelectConfig, data orm.QueryData, where orm.QueryData) ![][]orm.Primitive { + conf := orm.SelectConfig{ + ...config + table: reformat_table_name(config.table) + } + mut query := orm.orm_select_gen(conf, '', true, ':', 1, where) + query = query_reformatter(query, [data, where]) + mut rows := Result{} + if data.data.len == 0 && where.data.len == 0 { + rows = db.query(query) or { return err } + } else { + values := get_table_values_map(mut db, config.table, [data, where]) or { return err } + mut stmt := db.prepare(query) or { return err } + rows = stmt.query(values) or { return err } + } + + mut ret := [][]orm.Primitive{} + for row in rows { + mut row_primitives := []orm.Primitive{} + keys := row.data.keys() + for _, key in keys { + prim := row.get(key)! + row_primitives << prim.primitive() or { return err } + } + ret << row_primitives + } + + return ret +} + +// insert is used internally by V's ORM for processing `INSERT` queries +pub fn (mut db Connection) insert(table string, data orm.QueryData) ! { + new_table := reformat_table_name(table) + mut tbl := get_table_values_map(mut db, table, [data]) or { return err } + mut query := 'INSERT INTO ${new_table} (${data.fields.join(', ')}) VALUES (:${data.fields.join(', :')})' + // if a table has a serial field, we need to remove it from the insert statement + // only allows one serial field per table, as do most RDBMS + if data.auto_fields.len > 1 { + return error('multiple AUTO fields are not supported') + } else if data.auto_fields.len == 1 { + autofield_idx := data.auto_fields[0] + autofield := data.fields[autofield_idx] + tbl.delete(autofield) + query = query.replace(':${autofield}', 'NEXT VALUE FOR "${serial_name(table)}"') + } + mut stmt := db.prepare(query) or { return err } + stmt.query(tbl) or { return err } +} + +// update is used internally by V's ORM for processing `UPDATE` queries +pub fn (mut db Connection) update(table string, data orm.QueryData, where orm.QueryData) ! { + new_table := reformat_table_name(table) + mut query, _ := orm.orm_stmt_gen(.sqlite, new_table, '', .update, true, ':', 1, data, + where) + values := get_table_values_map(mut db, table, [data, where]) or { return err } + query = query_reformatter(query, [data, where]) + mut stmt := db.prepare(query) or { return err } + stmt.query(values) or { return err } +} + +// delete is used internally by V's ORM for processing `DELETE ` queries +pub fn (mut db Connection) delete(table string, where orm.QueryData) ! { + new_table := reformat_table_name(table) + mut query, _ := orm.orm_stmt_gen(.sqlite, new_table, '', .delete, true, ':', 1, orm.QueryData{}, + where) + query = query_reformatter(query, [where]) + values := get_table_values_map(mut db, table, [where]) or { return err } + mut stmt := db.prepare(query) or { return err } + stmt.query(values) or { return err } +} + +// `last_id` is used internally by V's ORM for post-processing `INSERT` queries +//