diff --git a/README.md b/README.md index 48a2324..aab50d8 100644 --- a/README.md +++ b/README.md @@ -6,22 +6,40 @@ A gleam library for operating and querying ETS tables. ```gleam -import lamb +import gleam/list +import lamb.{Set, Private} +import lamb/query.{v, i, a} as q pub fn main() { - let assert Ok(table) = lamb.create_table(Private, "test_table") + // Create a table and insert 5 records. + let assert Ok(table) = lamb.create_table(Set, Private, "test_table") - lamb.insert(table, "a", 1) - lamb.insert(table, "b", 2) - lamb.insert(table, "c", 3) - lamb.insert(table, "d", 4) - lamb.insert(table, "e", 5) + lamb.insert(table, [ + #("a", 1), + #("b", 2), + #("c", 3), + #("d", 4), + #("e", 5), + ]) - let assert Records([_, _] as a, step) = lamb.partial(table, by: 2) + // Retrieve just 1 record. + let assert Ok(3) = lamb.get(table, "c") + + // This query syntax builds a matchspec to make queries on the table. + let query = + q.new() + |> q.bind(#(v(0), v(1))) + + // Retrieve all rows but only return the record + let _records = lamb.all(table, query |> q.map(v(1))) + + // Retrieve all rows but only return the record + let _records = lamb.all(table, query |> q.map(v(0))) + + // Retrieve all records in batches of 2. + let assert Records([_, _] as a, step) = lamb.partial(table, by: 2, where: q.new()) let assert Records([_, _] as b, step) = lamb.continue(step) let assert End([_] as c) = lamb.continue(step) - - lamb.delete_table(table) } ``` diff --git a/gleam.toml b/gleam.toml index dc9b470..da42c7b 100644 --- a/gleam.toml +++ b/gleam.toml @@ -1,5 +1,5 @@ name = "lamb" -version = "0.0.3" +version = "0.1.0" # Fill out these fields if you intend to generate HTML documentation or publish # your project to the Hex package manager. diff --git a/src/lamb.gleam b/src/lamb.gleam index 94d6c8a..2d246b4 100644 --- a/src/lamb.gleam +++ b/src/lamb.gleam @@ -4,6 +4,9 @@ import lamb/query.{type Query} // ------ Table API ------ // +// TODO: Make tables of different types. Set, OrderedSet, Bag, Duplicate Bag. +// TODO: Think about enabling the heir option. +// TODO: Think about enabling th give away option (which is independent from heir). pub opaque type Table(index, record) { Table(reference: TableId) } @@ -14,9 +17,8 @@ type TableId = type Name = atom.Atom -pub type Partial(record) { - Records(List(record), Step) - End(List(record)) +pub type Store { + Set } // TODO: Private and Protected should probably carry a subject. @@ -28,6 +30,7 @@ pub type Access { } pub fn create_table( + store: Store, access: Access, name: String, ) -> Result(Table(index, record), Nil) { @@ -36,14 +39,14 @@ pub fn create_table( case access { Private -> { - let table_id = ffi_create_table(access, name) + let table_id = ffi_create_table(store, access, name) Ok(Table(table_id)) } Protected | Public -> { case ffi_table_id(name) { Ok(_table_id) -> Error(Nil) - Error(Nil) -> ffi_create_table(access, name) |> from_atom() + Error(Nil) -> ffi_create_table(store, access, name) |> from_atom() } } } @@ -85,22 +88,59 @@ pub fn is_alive(table: Table(index, record)) -> Bool { // ------ Record API ------ // -pub fn insert(table: Table(index, record), index: index, record: record) -> Nil { +// TODO: When inserting, updating, deleting, querying. Check the table type and +// who is making the request, who the process owner is to validate that the +// query can be made. +// +// Check if ets already gives us a useful type to work with. +// +// TODO: We can make all these operations work o lists. However the behaviour will +// need to be modified to guarantee insertion. +// +// > If the list contains more than one object with matching keys and the table +// > type is set, one is inserted, which one is not defined. The same holds for +// > table type ordered_set if the keys compare equal. +pub fn insert(table: Table(index, record), rows: List(#(index, record))) -> Nil { let Table(table_id) = table - let assert True = ffi_insert(table_id, #(index, record)) + let assert True = ffi_insert(table_id, rows) Nil } -pub fn delete(table: Table(index, record), index: index) -> Nil { +pub fn update( + table: Table(index, record), + where query: Query(index, record), +) -> Int { + // TODO: Consider these scenarios for bat tables: + // + // https://www.erlang.org/doc/apps/stdlib/ets.html#update_element/4 + // + // >The function fails with reason badarg in the following situations: + // - The table type is not set or ordered_set. + // - The element to update is also the key. + + // + // https://www.erlang.org/doc/apps/stdlib/ets.html#select_replace/2 + // + // > For the moment, due to performance and semantic constraints, tables of type bag + // are not yet supported. let Table(table_id) = table - let assert True = ffi_delete(table_id, index) + ffi_update(table_id, [query]) +} - Nil +pub fn delete( + table: Table(index, record), + where query: Query(index, record), +) -> Int { + let Table(table_id) = table + ffi_delete(table_id, [query |> query.map(True)]) } // ------ Query API ------ // +// TODO: For tables of type bag, instead of doing a different module we could +// just do a runtime check before ther request. I think this could make +// the API way more ergonomic. pub fn get(table: Table(index, record), index: index) -> Result(record, Nil) { let Table(table_id) = table ffi_get(table_id, index) @@ -108,27 +148,24 @@ pub fn get(table: Table(index, record), index: index) -> Result(record, Nil) { pub fn all( from table: Table(index, record), - where queries: List(Query(index, record)), + where query: Query(index, record), ) -> List(x) { let Table(table_id) = table + ffi_search(table_id, [query]) +} - case queries { - [] -> ffi_search(table_id, [query.new()]) - queries -> ffi_search(table_id, queries) - } +pub type Partial(record) { + Records(List(record), Step) + End(List(record)) } pub fn batch( from table: Table(index, record), by limit: Int, - where queries: List(Query(index, record)), -) -> Partial(record) { + where query: Query(index, record), +) -> Partial(x) { let Table(table_id) = table - - case queries { - [] -> ffi_search_partial(table_id, limit, [query.new()]) - queries -> ffi_search_partial(table_id, limit, queries) - } + ffi_search_partial(table_id, limit, [query]) } pub fn continue(step: Step) -> Partial(x) { @@ -137,14 +174,10 @@ pub fn continue(step: Step) -> Partial(x) { pub fn count( from table: Table(index, record), - where queries: List(Query(index, record)), + where query: Query(index, record), ) -> Int { let Table(table_id) = table - - case queries { - [] -> ffi_count(table_id, [query.new()]) - queries -> ffi_count(table_id, queries) - } + ffi_count(table_id, [query |> query.map(True)]) } // ------ FFI Helpers ------ // @@ -155,32 +188,35 @@ pub type Step fn ffi_table_id(table: name_or_ref) -> Result(TableId, Nil) @external(erlang, "lamb_erlang_ffi", "create_table") -fn ffi_create_table(access: Access, name: Name) -> name_or_ref +fn ffi_create_table(store: Store, access: Access, name: Name) -> name_or_ref @external(erlang, "ets", "delete") fn ffi_delete_table(table: TableId) -> TableId @external(erlang, "ets", "insert") -fn ffi_insert(table: TableId, record: record) -> Bool +fn ffi_insert(table: TableId, rows: List(#(index, record))) -> Bool -@external(erlang, "ets", "delete") -fn ffi_delete(table: TableId, index: index) -> Bool +@external(erlang, "ets", "select_replace") +fn ffi_update(table: TableId, queries: List(Query(index, record))) -> Int + +@external(erlang, "ets", "select_delete") +fn ffi_delete(table: TableId, queries: List(Query(index, record))) -> Int @external(erlang, "lamb_erlang_ffi", "get") fn ffi_get(table: TableId, index: index) -> Result(record, Nil) -@external(erlang, "lamb_erlang_ffi", "search") -fn ffi_search(table: TableId, query: List(Query(index, record))) -> List(x) +@external(erlang, "ets", "select") +fn ffi_search(table: TableId, queries: List(Query(index, record))) -> List(x) @external(erlang, "lamb_erlang_ffi", "search") fn ffi_search_partial( table: TableId, limit: Int, - query: List(Query(index, record)), + queries: List(Query(index, record)), ) -> Partial(x) @external(erlang, "lamb_erlang_ffi", "search") fn ffi_search_partial_continue(step: Step) -> Partial(x) -@external(erlang, "lamb_erlang_ffi", "count") -fn ffi_count(table: TableId, query: List(Query(index, record))) -> Int +@external(erlang, "ets", "select_count") +fn ffi_count(table: TableId, queries: List(Query(index, record))) -> Int diff --git a/src/lamb/query.gleam b/src/lamb/query.gleam index e316b07..a05a47b 100644 --- a/src/lamb/query.gleam +++ b/src/lamb/query.gleam @@ -2,7 +2,6 @@ import gleam/erlang/atom.{type Atom} import gleam/erlang/charlist.{type Charlist} import gleam/int import gleam/list -import gleam/string pub type Query(index, record) @@ -41,11 +40,36 @@ pub fn v(at position: Int) -> Atom { } } +// TODO: Can these be made Head or Body specific? pub fn a(name: String) -> Atom { - let name = string.lowercase(name) atom.create_from_string(name) } +pub fn t1(a: a) { + #(#(a)) +} + +pub fn t2(a: a, b: b) { + #(#(a, b)) +} + +pub fn t3(a: a, b: b, c: c) { + #(#(a, b, c)) +} + +pub fn t4(a: a, b: b, c: c, d: d) { + #(#(a, b, c, d)) +} + +pub fn t5(a: a, b: b, c: c, d: d, e: e) { + #(#(a, b, c, d, e)) +} + +pub fn r(name: String, tuple: tuple) -> x { + let tag = a(name) + ffi_body_record(tag, tuple) +} + pub fn validate( query: Query(index, record), ) -> Result(Query(index, record), List(String)) { @@ -72,3 +96,6 @@ type Test(body) { @external(erlang, "lamb_query_erlang_ffi", "test_query") fn ffi_test_query(query: Query(index, record), row: row) -> Test(body) + +@external(erlang, "lamb_query_erlang_ffi", "body_record") +fn ffi_body_record(name: Atom, body: body) -> x diff --git a/src/lamb_erlang_ffi.erl b/src/lamb_erlang_ffi.erl index 7b72ee3..4c248c8 100644 --- a/src/lamb_erlang_ffi.erl +++ b/src/lamb_erlang_ffi.erl @@ -1,18 +1,16 @@ -module(lamb_erlang_ffi). --export([create_table/2, table_id/1, get/2, search/1, search/2, search/3, count/2]). - -create_table(Access, Name) when is_atom(Access), is_atom(Name) -> - DefaultOptions = [set], +-export([create_table/3, table_id/1, get/2, search/1, search/3]). +create_table(Store, Access, Name) when is_atom(Access), is_atom(Name) -> Options = case Access of private -> - [private | DefaultOptions]; + [Store, private]; protected -> - [protected, named_table | DefaultOptions]; + [Store, protected, named_table]; public -> - [public, named_table | DefaultOptions] + [Store, public, named_table] end, ets:new(Name, Options). @@ -37,9 +35,6 @@ search(Step) -> Result = ets:select(Step), partial_handle(Result). -search(Step, MatchExpression) -> - ets:select(Step, MatchExpression). - search(TableId, Limit, MatchExpression) -> Result = ets:select(TableId, MatchExpression, Limit), partial_handle(Result). @@ -53,9 +48,3 @@ partial_handle(Result) -> '$end_of_table' -> {'end', []} end. - -count(TableId, MatchExpression) -> - NewMatchExpression = - lists:map(fun({Head, Conditions, _Body}) -> {Head, Conditions, [true]} end, - MatchExpression), - ets:select_count(TableId, NewMatchExpression). diff --git a/test/artifacts/record.gleam b/test/artifacts/record.gleam index 65c3ceb..4ad56ff 100644 --- a/test/artifacts/record.gleam +++ b/test/artifacts/record.gleam @@ -15,12 +15,24 @@ pub fn random(id: Int) -> Record { pub fn generate(enum: Int, id: Int) -> Record { case enum { - 0 -> User(id: id, name: gen_name(), age: gen_age(), bio: gen_bio()) - 1 -> Client(id: id, x: gen_location(), y: gen_location()) - _ -> Admin(id: id) + 0 -> generate_user(id) + 1 -> generate_client(id) + _ -> generate_admin(id) } } +pub fn generate_user(id: Int) -> Record { + User(id: id, name: gen_name(), age: gen_age(), bio: gen_bio()) +} + +pub fn generate_client(id: Int) -> Record { + Client(id: id, x: gen_location(), y: gen_location()) +} + +pub fn generate_admin(id: Int) -> Record { + Admin(id: id) +} + fn gen_name() -> String { let assert [name, ..] = list.shuffle(["Raúl", "Carlos", "César", "Adrián"]) name diff --git a/test/artifacts/setup.gleam b/test/artifacts/setup.gleam index fd8ad5a..535aebf 100644 --- a/test/artifacts/setup.gleam +++ b/test/artifacts/setup.gleam @@ -1,11 +1,11 @@ import artifacts/record import gleam/io import gleam/iterator -import lamb.{type Table, Private} +import lamb.{type Table, Private, Set} pub fn table(name: String, function: fn(Table(index, record)) -> x) -> Nil { // Initialization - let assert Ok(table) = lamb.create_table(Private, name) + let assert Ok(table) = lamb.create_table(Set, Private, name) io.print("\n") io.print("test: " <> name) @@ -20,21 +20,24 @@ pub fn table(name: String, function: fn(Table(index, record)) -> x) -> Nil { } pub fn users_table(records quantity: Int) -> Table(Int, record.Record) { - let assert Ok(table) = lamb.create_table(Private, "test_users") + let assert Ok(table) = lamb.create_table(Set, Private, "test_users") - let enum = + let enums = iterator.from_list([0, 1, 2]) |> iterator.cycle() - |> iterator.take(quantity) - let id = iterator.range(0, quantity) + let ids = iterator.range(1, quantity) - iterator.zip(enum, id) - |> iterator.each(fn(x) { - let #(enum, id) = x - let record = record.generate(enum, id) - lamb.insert(table, id, record) - }) + iterator.map2(enums, ids, record.generate) + |> iterator.each(fn(record) { lamb.insert(table, [#(record.id, record)]) }) table } + +pub fn dbg(term: term) -> term { + term |> display() + term +} + +@external(erlang, "erlang", "display") +fn display(term: term) -> term diff --git a/test/lamb_test.gleam b/test/lamb_test.gleam index 328cad3..bcbe3ef 100644 --- a/test/lamb_test.gleam +++ b/test/lamb_test.gleam @@ -2,8 +2,8 @@ import artifacts/record.{Admin, User} import artifacts/setup import gleam/list import gleeunit -import lamb.{End, Private, Protected, Public, Records} -import lamb/query.{a, i, v} as q +import lamb.{End, Private, Protected, Public, Records, Set} +import lamb/query.{a, i, t2, v} as q pub fn main() { gleeunit.main() @@ -11,19 +11,19 @@ pub fn main() { pub fn table_test() { // Able to create a private table - let assert Ok(t0) = lamb.create_table(Private, "test_table") + let assert Ok(t0) = lamb.create_table(Set, Private, "test_table") let assert True = lamb.is_alive(t0) // Able to create a private table with same name - let assert Ok(t1) = lamb.create_table(Private, "test_table") + let assert Ok(t1) = lamb.create_table(Set, Private, "test_table") let assert True = lamb.is_alive(t1) // Able to create a protected table - let assert Ok(t2) = lamb.create_table(Protected, "test_table") + let assert Ok(t2) = lamb.create_table(Set, Protected, "test_table") let assert True = lamb.is_alive(t2) // Unable to create a public table with same name - let assert Error(_) = lamb.create_table(Public, "test_table") + let assert Error(_) = lamb.create_table(Set, Public, "test_table") // Able to retrieve a table by name let assert Ok(_) = lamb.from_name("test_table") @@ -42,42 +42,82 @@ pub fn table_test() { pub fn record_test() { setup.table("insert records", fn(table) { - let assert [] = lamb.all(table, []) - lamb.insert(table, 1, record.random(1)) - let assert [_] = lamb.all(table, []) - lamb.insert(table, 2, record.random(2)) - let assert [_, _] = lamb.all(table, []) + let assert [] = lamb.all(table, q.new()) + + lamb.insert(table, [#("a", record.random(1))]) + let assert [_] = lamb.all(table, q.new()) + + lamb.insert(table, [#("b", record.random(2))]) + let assert [_, _] = lamb.all(table, q.new()) + + lamb.insert(table, [#("c", record.random(3))]) + let assert [_, _, _] = lamb.all(table, q.new()) }) + + setup.table("update records", fn(table) { + lamb.insert(table, [ + #("a", record.generate_user(1)), + #("b", record.generate_user(2)), + #("c", record.generate_user(3)), + ]) + + let assert [User(_, _, _, _), User(_, _, _, _), User(_, _, _, _)] = + lamb.all(table, q.new()) + + let query = + q.new() + |> q.bind(#(v(0), #(a("user"), v(1), i(), i(), i()))) + |> q.map(#(v(0), t2(a("admin"), v(1)))) + + let assert 3 = lamb.update(table, query) + let assert [Admin(_), Admin(_), Admin(_)] = lamb.all(table, q.new()) + }) + setup.table("delete records", fn(table) { - let assert [] = lamb.all(table, []) - lamb.insert(table, 1, record.random(1)) - let assert [_] = lamb.all(table, []) - lamb.delete(table, 1) - let assert [] = lamb.all(table, []) + let assert [] = lamb.all(table, q.new()) + + lamb.insert(table, [ + #("a", record.random(1)), + #("b", record.random(2)), + #("c", record.random(3)), + ]) + + let assert 3 = lamb.delete(table, q.new()) + let assert [] = lamb.all(table, q.new()) }) } pub fn simple_query_test() { setup.table("get", fn(table) { let assert Error(_) = lamb.get(table, "a") - lamb.insert(table, "a", Admin(id: 1)) + + lamb.insert(table, [ + #("a", record.random(1)), + #("b", record.random(2)), + #("c", record.random(3)), + ]) + let assert Ok(_) = lamb.get(table, "a") + let assert Ok(_) = lamb.get(table, "b") + let assert Ok(_) = lamb.get(table, "c") + let assert Error(_) = lamb.get(table, "d") }) setup.table("retrieve all", fn(table) { - lamb.insert(table, "a", Admin(id: 1)) - lamb.insert(table, "b", Admin(id: 2)) - let assert [Admin(_), Admin(_)] = lamb.all(table, []) + lamb.insert(table, [#("a", Admin(1)), #("b", Admin(2))]) + let assert [Admin(_), Admin(_)] = lamb.all(table, q.new()) }) setup.table("retrieve partial", fn(table) { - lamb.insert(table, "a", Admin(id: 1)) - lamb.insert(table, "b", Admin(id: 2)) - lamb.insert(table, "c", Admin(id: 3)) - lamb.insert(table, "d", Admin(id: 4)) - lamb.insert(table, "e", Admin(id: 5)) - - let assert Records([_, _] as a, step) = lamb.batch(table, by: 2, where: []) + lamb.insert(table, [ + #("a", Admin(id: 1)), + #("b", Admin(id: 2)), + #("c", Admin(id: 3)), + #("d", Admin(id: 4)), + #("e", Admin(id: 5)), + ]) + + let assert Records([_, _] as a, step) = lamb.batch(table, 2, q.new()) let assert Records([_, _] as b, step) = lamb.continue(step) let assert End([_] as c) = lamb.continue(step) @@ -86,18 +126,28 @@ pub fn simple_query_test() { }) setup.table("count", fn(table) { - lamb.insert(table, "a", 1) - lamb.insert(table, "b", 2) - lamb.insert(table, "c", 3) - lamb.insert(table, "d", 4) - lamb.insert(table, "e", 5) - - let assert 5 = lamb.count(table, []) + lamb.insert(table, [ + #("a", Admin(id: 1)), + #("b", Admin(id: 2)), + #("c", Admin(id: 3)), + #("d", Admin(id: 4)), + #("e", Admin(id: 5)), + ]) + + let assert 5 = lamb.count(table, q.new()) }) } pub fn complex_query_test() { - True + setup.table("test", fn(table) { + lamb.insert(table, [#("a", 1), #("b", 2), #("c", 3), #("d", 4), #("e", 5)]) + + let assert ["c"] = + q.new() + |> q.bind(#(v(0), 3)) + |> q.map(v(0)) + |> lamb.all(table, _) + }) } pub fn debugging_test() { @@ -124,41 +174,33 @@ pub fn debugging_test() { let assert Ok(_query) = q.new() - |> q.bind(#(a("User"), i(), "Raúl", v(0), i())) + |> q.bind(#(a("user"), i(), "Raúl", v(0), i())) |> q.map(v(0)) |> q.against(User(1, "Raúl", 35, "")) let assert Error(_query) = q.new() - |> q.bind(#(a("User"), i(), "Raúl", v(0), i())) + |> q.bind(#(a("user"), i(), "Raúl", v(0), i())) |> q.map(v(0)) |> q.against(User(1, "Carlos", 30, "")) } pub fn parse_tranform_query_test() { let table = setup.users_table(records: 33_333) - let assert 33_333 = lamb.count(table, []) + let assert 33_333 = lamb.count(table, q.new()) - let query_user = query_user() - let query_client = query_client() - let query_admin = query_admin() + let assert [query_user] = query_user() + let assert [query_client] = query_client() + let assert [query_admin] = query_admin() let assert 11_111 = lamb.count(table, query_user) let assert 11_111 = lamb.count(table, query_client) let assert 11_111 = lamb.count(table, query_admin) - let query = list.concat([query_user, query_client]) - let assert 22_222 = lamb.count(table, query) - - let query = list.concat([query_client, query_admin]) - let assert 22_222 = lamb.count(table, query) - - let query = list.concat([query_user, query_client, query_admin]) - let assert 33_333 = lamb.count(table, query) - - let query = query_when_name_is("Raúl") - let users = lamb.all(table, query) - let assert True = 11_111 > list.length(users) + let assert True = + 11_111 + > lamb.all(table, when_name(is: "Raúl")) + |> list.length() lamb.delete_table(table) } @@ -172,12 +214,10 @@ fn query_client() -> List(q.Query(index, record)) @external(erlang, "artifacts_queries_ffi", "query_admin") fn query_admin() -> List(q.Query(index, record)) +fn when_name(is name: String) -> q.Query(index, record) { + let assert [query] = query_when_name_is(name) + query +} + @external(erlang, "artifacts_queries_ffi", "query_where_name_is") fn query_when_name_is(name: String) -> List(q.Query(index, record)) -// fn dbg(term: term) -> term { -// term |> display() -// term -// } -// -// @external(erlang, "erlang", "display") -// fn display(term: term) -> term