diametric 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/.gitignore +22 -0
- data/Gemfile +14 -0
- data/Guardfile +8 -0
- data/Jarfile +6 -0
- data/Jarfile.lock +126 -0
- data/LICENSE.txt +22 -0
- data/README.md +215 -0
- data/Rakefile +17 -0
- data/TODO.org +12 -0
- data/diametric.gemspec +31 -0
- data/lib/diametric.rb +9 -0
- data/lib/diametric/entity.rb +339 -0
- data/lib/diametric/persistence/common.rb +29 -0
- data/lib/diametric/persistence/peer.rb +107 -0
- data/lib/diametric/persistence/rest.rb +88 -0
- data/lib/diametric/query.rb +157 -0
- data/lib/diametric/version.rb +3 -0
- data/lib/jrclj.rb +63 -0
- data/spec/diametric/entity_spec.rb +128 -0
- data/spec/diametric/persistence/peer_spec.rb +36 -0
- data/spec/diametric/persistence/rest_spec.rb +30 -0
- data/spec/diametric/query_spec.rb +63 -0
- data/spec/integration_spec.rb +67 -0
- data/spec/spec_helper.rb +53 -0
- data/spec/support/persistence_examples.rb +68 -0
- metadata +164 -0
@@ -0,0 +1,88 @@
|
|
1
|
+
require 'diametric'
|
2
|
+
require 'diametric/persistence/common'
|
3
|
+
require 'datomic/client'
|
4
|
+
|
5
|
+
module Diametric
|
6
|
+
module Persistence
|
7
|
+
module REST
|
8
|
+
@connection = nil
|
9
|
+
@persisted_classes = Set.new
|
10
|
+
|
11
|
+
def self.included(base)
|
12
|
+
base.send(:include, Diametric::Persistence::Common)
|
13
|
+
base.send(:extend, ClassMethods)
|
14
|
+
@persisted_classes.add(base)
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.connection
|
18
|
+
@connection
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.create_schemas
|
22
|
+
@persisted_classes.each do |klass|
|
23
|
+
klass.create_schema
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
module ClassMethods
|
28
|
+
def connect(uri, dbalias, database)
|
29
|
+
@uri = uri
|
30
|
+
@dbalias = dbalias
|
31
|
+
@database = database
|
32
|
+
|
33
|
+
@connection = Datomic::Client.new(uri, dbalias)
|
34
|
+
@connection.create_database(database)
|
35
|
+
end
|
36
|
+
|
37
|
+
def connection
|
38
|
+
@connection || Diametric::Persistence::REST.connection
|
39
|
+
end
|
40
|
+
|
41
|
+
def database
|
42
|
+
@database || Diametric::Persistence::REST.database
|
43
|
+
end
|
44
|
+
|
45
|
+
def transact(data)
|
46
|
+
connection.transact(database, data)
|
47
|
+
end
|
48
|
+
|
49
|
+
def get(dbid)
|
50
|
+
res = connection.entity(database, dbid)
|
51
|
+
|
52
|
+
# TODO tighten regex to only allow fields with the model name
|
53
|
+
attrs = res.data.map { |attr_symbol, value|
|
54
|
+
attr = attr_symbol.to_s.gsub(%r"^\w+/", '')
|
55
|
+
[attr, value]
|
56
|
+
}
|
57
|
+
|
58
|
+
entity = self.new(Hash[*attrs.flatten])
|
59
|
+
entity.dbid = dbid
|
60
|
+
entity
|
61
|
+
end
|
62
|
+
|
63
|
+
def q(query, args)
|
64
|
+
args.unshift(connection.db_alias(database))
|
65
|
+
res = connection.query(query, args)
|
66
|
+
res.data
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
extend ClassMethods
|
71
|
+
|
72
|
+
def save
|
73
|
+
return false unless valid?
|
74
|
+
return true unless changed?
|
75
|
+
|
76
|
+
res = self.class.transact(tx_data)
|
77
|
+
if dbid.nil?
|
78
|
+
self.dbid = res.data[:tempids].values.first
|
79
|
+
end
|
80
|
+
|
81
|
+
@previously_changed = changes
|
82
|
+
@changed_attributes.clear
|
83
|
+
|
84
|
+
res
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,157 @@
|
|
1
|
+
require 'diametric'
|
2
|
+
|
3
|
+
module Diametric
|
4
|
+
# +Query+ objects are used to generate Datomic queries, whether to
|
5
|
+
# send via an external client or via the persistence API. The two
|
6
|
+
# methods used to generate a query are +.where+ and +.filter+, both
|
7
|
+
# of which are chainable. To get the query data and arguments for a
|
8
|
+
# +Query+, use the +data+ method.
|
9
|
+
#
|
10
|
+
# If you are using a persistence API, you can ask +Query+ to get the
|
11
|
+
# results of a Datomic query. +Diametric::Query+ is an
|
12
|
+
# +Enumerable+. To get the results of a query, use +Enumerable+
|
13
|
+
# methods such as +.each+ or +.first+. +Query+ also provides a
|
14
|
+
# +.all+ method to run the query and get the results.
|
15
|
+
class Query
|
16
|
+
include Enumerable
|
17
|
+
|
18
|
+
attr_reader :conditions, :filters, :model
|
19
|
+
|
20
|
+
# Create a new Datomic query.
|
21
|
+
#
|
22
|
+
# @param model [Entity] This model must include +Datomic::Entity+. Including
|
23
|
+
# a persistence module is optional.
|
24
|
+
def initialize(model)
|
25
|
+
@model = model
|
26
|
+
@conditions = {}
|
27
|
+
@filters = []
|
28
|
+
end
|
29
|
+
|
30
|
+
# Add conditions to your Datomic query. Conditions check for equality
|
31
|
+
# against entity attributes. In addition, you can add conditions for
|
32
|
+
# use as variables in filters.
|
33
|
+
#
|
34
|
+
# @example Looking for mice named Wilbur.
|
35
|
+
# Query.new(Mouse).conditions(:name => "Wilbur")
|
36
|
+
#
|
37
|
+
# @param conditions [Hash] Datomic variables and values.
|
38
|
+
# @return [Query]
|
39
|
+
def where(conditions)
|
40
|
+
query = self.dup
|
41
|
+
query.conditions = query.conditions.merge(conditions)
|
42
|
+
query
|
43
|
+
end
|
44
|
+
|
45
|
+
# Add a filter to your Datomic query. Filters are known as expression clause
|
46
|
+
# predicates in the {Datomic query documentation}[http://docs.datomic.com/query.html].
|
47
|
+
#
|
48
|
+
# A filter can be in one of two forms. In the first, you pass a
|
49
|
+
# series of arguments. Any Ruby symbol given in this form will be
|
50
|
+
# converted to a EDN symbol. If the symbol is the same as one of
|
51
|
+
# the queried model's attributes or as a key passed to +where+, it
|
52
|
+
# will be prefixed with a +?+ so that it becomes a Datalog
|
53
|
+
# variable. In the second form, you pass
|
54
|
+
# {EDN}[https://github.com/relevance/edn-ruby] representing a
|
55
|
+
# Datomic predicate to +filter+. No conversion is done on this
|
56
|
+
# filter and it must be an EDN list.
|
57
|
+
#
|
58
|
+
# @example Passing arguments to be converted.
|
59
|
+
# query.filter(:>, :age, 21)
|
60
|
+
#
|
61
|
+
# @example Passing EDN, which will not be converted.
|
62
|
+
# query.filter(EDN::Type::List.new(EDN::Type::Symbol(">"),
|
63
|
+
# EDN::Type::Symbol("?age"),
|
64
|
+
# 21))
|
65
|
+
# # or, more simply
|
66
|
+
# query.filter(~[~">", ~"?age", 21])
|
67
|
+
#
|
68
|
+
# @param filter [Array] Either one +EDN::Type::List+ or a number of arguments
|
69
|
+
# that will be converted into a Datalog query.
|
70
|
+
# @return [Query]
|
71
|
+
def filter(*filter)
|
72
|
+
query = self.dup
|
73
|
+
|
74
|
+
if filter.first.is_a?(EDN::Type::List)
|
75
|
+
filter = filter.first
|
76
|
+
else
|
77
|
+
filter = filter.map { |e| convert_filter_element(e) }
|
78
|
+
filter = EDN::Type::List.new(*filter)
|
79
|
+
end
|
80
|
+
|
81
|
+
query.filters += [[filter]]
|
82
|
+
query
|
83
|
+
end
|
84
|
+
|
85
|
+
# Loop through the query results. In order to use +each+, your model *must*
|
86
|
+
# include a persistence API. At a minimum, it must have a +.q+ method that
|
87
|
+
# returns an +Enumerable+ object.
|
88
|
+
#
|
89
|
+
# @yield [Entity] An instance of the model passed to +Query+.
|
90
|
+
def each
|
91
|
+
# TODO check to see if the model has a `.q` method and give
|
92
|
+
# an appropriate error if not.
|
93
|
+
res = model.q(*data)
|
94
|
+
res.each do |entity|
|
95
|
+
# The map is for compatibility with Java peer persistence.
|
96
|
+
# TODO remove if possible
|
97
|
+
yield model.from_query(entity.map { |x| x })
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
# Return all query results.
|
102
|
+
#
|
103
|
+
# @return [Array<Entity>] Query results.
|
104
|
+
def all
|
105
|
+
map { |x| x }
|
106
|
+
end
|
107
|
+
|
108
|
+
# Create a Datomic query from the conditions and filters passed to this
|
109
|
+
# +Query+ object.
|
110
|
+
#
|
111
|
+
# @return [Array(Array, Array)] The first element of the array returned
|
112
|
+
# is the Datomic query composed of Ruby data. The second element is
|
113
|
+
# the arguments that used with the query.
|
114
|
+
def data
|
115
|
+
vars = model.attributes.map { |attribute, _, _| ~"?#{attribute}" }
|
116
|
+
|
117
|
+
from = conditions.map { |k, _| ~"?#{k}" }
|
118
|
+
|
119
|
+
clauses = model.attributes.map { |attribute, _, _|
|
120
|
+
[~"?e", model.namespace(model.prefix, attribute), ~"?#{attribute}"]
|
121
|
+
}
|
122
|
+
clauses += filters
|
123
|
+
|
124
|
+
args = conditions.map { |_, v| v }
|
125
|
+
|
126
|
+
query = [
|
127
|
+
:find, ~"?e", *vars,
|
128
|
+
:in, ~"\$", *from,
|
129
|
+
:where, *clauses
|
130
|
+
]
|
131
|
+
|
132
|
+
[query, args]
|
133
|
+
end
|
134
|
+
|
135
|
+
protected
|
136
|
+
|
137
|
+
def conditions=(conditions)
|
138
|
+
@conditions = conditions
|
139
|
+
end
|
140
|
+
|
141
|
+
def filters=(filters)
|
142
|
+
@filters = filters
|
143
|
+
end
|
144
|
+
|
145
|
+
def convert_filter_element(element)
|
146
|
+
if element.is_a?(Symbol)
|
147
|
+
if model.attribute_names.include?(element) || @conditions.keys.include?(element)
|
148
|
+
EDN::Type::Symbol.new("?#{element}")
|
149
|
+
else
|
150
|
+
EDN::Type::Symbol.new(element.to_s)
|
151
|
+
end
|
152
|
+
else
|
153
|
+
element
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
data/lib/jrclj.rb
ADDED
@@ -0,0 +1,63 @@
|
|
1
|
+
require 'java'
|
2
|
+
|
3
|
+
java_import "clojure.lang.RT"
|
4
|
+
|
5
|
+
class JRClj
|
6
|
+
def initialize *pkgs
|
7
|
+
@mappings = {}
|
8
|
+
@ns_map = RT.var "clojure.core", "ns-map"
|
9
|
+
@symbol = RT.var "clojure.core", "symbol"
|
10
|
+
@require = RT.var "clojure.core", "require"
|
11
|
+
_import "clojure.core"
|
12
|
+
pkgs.each do |pkg|
|
13
|
+
_import pkg
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def _import pkg_name, sym=nil, sym_alias=nil
|
18
|
+
@require.invoke @symbol.invoke(pkg_name)
|
19
|
+
if sym
|
20
|
+
sym_alias ||= sym
|
21
|
+
@mappings[sym_alias] = RT.var pkg_name, sym
|
22
|
+
return
|
23
|
+
end
|
24
|
+
pkg = @symbol.invoke pkg_name
|
25
|
+
@ns_map.invoke(pkg).each do |sym,var|
|
26
|
+
@mappings[sym.to_s] = var
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def _eval s
|
31
|
+
self.eval self.read_string(s)
|
32
|
+
end
|
33
|
+
|
34
|
+
def _invoke m, *args
|
35
|
+
fun = @mappings[m.to_s] || @mappings[m.to_s.gsub "_", "-"]
|
36
|
+
unless fun
|
37
|
+
raise "Error, no current binding for symbol=#{m}"
|
38
|
+
end
|
39
|
+
fun.invoke(*args)
|
40
|
+
end
|
41
|
+
|
42
|
+
def _alias new, old
|
43
|
+
@mappings[new] = @mappings[old]
|
44
|
+
end
|
45
|
+
|
46
|
+
def method_missing symbol, *args
|
47
|
+
_invoke symbol, *args
|
48
|
+
end
|
49
|
+
|
50
|
+
def self.persistent_map entries=[]
|
51
|
+
Java::ClojureLang::PersistentArrayMap.new entries.to_java
|
52
|
+
end
|
53
|
+
|
54
|
+
def edn_convert(obj)
|
55
|
+
self.read_string(obj.to_edn)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
class Symbol
|
60
|
+
def to_clj
|
61
|
+
Java::ClojureLang::Keyword.intern(self.to_s)
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,128 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Diametric::Entity do
|
4
|
+
describe "in a class" do
|
5
|
+
subject { Person }
|
6
|
+
|
7
|
+
it { should respond_to(:attribute) }
|
8
|
+
it { should respond_to(:schema) }
|
9
|
+
it { should respond_to(:from_query) }
|
10
|
+
|
11
|
+
it "should generate a schema" do
|
12
|
+
Person.schema.should == [
|
13
|
+
{ :"db/id" => Person.send(:tempid, :"db.part/db"),
|
14
|
+
:"db/ident" => :"person/name",
|
15
|
+
:"db/valueType" => :"db.type/string",
|
16
|
+
:"db/cardinality" => :"db.cardinality/one",
|
17
|
+
:"db/index" => true,
|
18
|
+
:"db.install/_attribute" => :"db.part/db" },
|
19
|
+
{ :"db/id" => Person.send(:tempid, :"db.part/db"),
|
20
|
+
:"db/ident" => :"person/email",
|
21
|
+
:"db/valueType" => :"db.type/string",
|
22
|
+
:"db/cardinality" => :"db.cardinality/many",
|
23
|
+
:"db.install/_attribute" => :"db.part/db" },
|
24
|
+
{ :"db/id" => Person.send(:tempid, :"db.part/db"),
|
25
|
+
:"db/ident" => :"person/birthday",
|
26
|
+
:"db/valueType" => :"db.type/instant",
|
27
|
+
:"db/cardinality" => :"db.cardinality/one",
|
28
|
+
:"db.install/_attribute" => :"db.part/db" },
|
29
|
+
{ :"db/id" => Person.send(:tempid, :"db.part/db"),
|
30
|
+
:"db/ident" => :"person/awesome",
|
31
|
+
:"db/valueType" => :"db.type/boolean",
|
32
|
+
:"db/cardinality" => :"db.cardinality/one",
|
33
|
+
:"db/doc" => "Is this person awesome?",
|
34
|
+
:"db.install/_attribute" => :"db.part/db" },
|
35
|
+
{ :"db/id" => Person.send(:tempid, :"db.part/db"),
|
36
|
+
:"db/ident" => :"person/ssn",
|
37
|
+
:"db/valueType" => :"db.type/string",
|
38
|
+
:"db/cardinality" => :"db.cardinality/one",
|
39
|
+
:"db/unique" => :"db.unique/value",
|
40
|
+
:"db.install/_attribute" => :"db.part/db" },
|
41
|
+
{ :"db/id" => Person.send(:tempid, :"db.part/db"),
|
42
|
+
:"db/ident" => :"person/secret_name",
|
43
|
+
:"db/valueType" => :"db.type/string",
|
44
|
+
:"db/cardinality" => :"db.cardinality/one",
|
45
|
+
:"db/unique" => :"db.unique/identity",
|
46
|
+
:"db.install/_attribute" => :"db.part/db" },
|
47
|
+
{ :"db/id" => Person.send(:tempid, :"db.part/db"),
|
48
|
+
:"db/ident" => :"person/bio",
|
49
|
+
:"db/valueType" => :"db.type/string",
|
50
|
+
:"db/cardinality" => :"db.cardinality/one",
|
51
|
+
:"db/fulltext" => true,
|
52
|
+
:"db.install/_attribute" => :"db.part/db" }
|
53
|
+
]
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
describe "in an instance" do
|
58
|
+
subject { Person.new }
|
59
|
+
let(:model) { Person.new }
|
60
|
+
|
61
|
+
it_should_behave_like "ActiveModel"
|
62
|
+
|
63
|
+
it { should respond_to(:tx_data) }
|
64
|
+
|
65
|
+
it "should handle attributes correctly" do
|
66
|
+
subject.name.should be_nil
|
67
|
+
subject.name = "Clinton"
|
68
|
+
subject.name.should == "Clinton"
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
describe ".new" do
|
73
|
+
it "should work without arguments" do
|
74
|
+
Person.new.should be_a(Person)
|
75
|
+
end
|
76
|
+
|
77
|
+
it "should assign attributes based off argument keys" do
|
78
|
+
person = Person.new(:name => "Dashiell D", :secret_name => "Monito")
|
79
|
+
person.name.should == "Dashiell D"
|
80
|
+
person.secret_name.should == "Monito"
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
describe ".from_query" do
|
85
|
+
it "should assign dbid and attributes" do
|
86
|
+
goat = Goat.from_query([1, "Beans", DateTime.parse("1976/9/4")])
|
87
|
+
goat.dbid.should == 1
|
88
|
+
goat.name.should == "Beans"
|
89
|
+
goat.birthday.should == DateTime.parse("1976/9/4")
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
describe "#tx_data" do
|
94
|
+
let(:goat) { Goat.new(:name => "Beans", :birthday => Date.parse("2002-04-15"))}
|
95
|
+
|
96
|
+
describe "without a dbid" do
|
97
|
+
it "should generate a transaction with a new tempid" do
|
98
|
+
# Equivalence is currently wrong on EDN tagged values.
|
99
|
+
tx = goat.tx_data.first
|
100
|
+
tx.keys.should == [:"db/id", :"goat/name", :"goat/birthday"]
|
101
|
+
tx[:"db/id"].to_edn.should match(%r"#db/id \[:db.part/user \-\d+\]")
|
102
|
+
tx[:"goat/name"].should == "Beans"
|
103
|
+
tx[:"goat/birthday"].should == goat.birthday
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
describe "with a dbid" do
|
108
|
+
it "should generate a transaction with the dbid" do
|
109
|
+
goat.dbid = 1
|
110
|
+
goat.tx_data.should == [
|
111
|
+
{ :"db/id" => 1,
|
112
|
+
:"goat/name" => "Beans",
|
113
|
+
:"goat/birthday" => goat.birthday
|
114
|
+
}
|
115
|
+
]
|
116
|
+
end
|
117
|
+
|
118
|
+
it "should generate a transaction with only specified attributes" do
|
119
|
+
goat.dbid = 1
|
120
|
+
goat.tx_data(:name).should == [
|
121
|
+
{ :"db/id" => 1,
|
122
|
+
:"goat/name" => "Beans"
|
123
|
+
}
|
124
|
+
]
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'diametric/persistence/peer' if RUBY_ENGINE == 'jruby'
|
3
|
+
|
4
|
+
# Prevent CRuby from blowing up
|
5
|
+
module Diametric
|
6
|
+
module Persistence
|
7
|
+
module Peer
|
8
|
+
end
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
describe Diametric::Persistence::Peer, :jruby do
|
13
|
+
class Rat
|
14
|
+
include Diametric::Entity
|
15
|
+
include Diametric::Persistence::Peer
|
16
|
+
|
17
|
+
attribute :name, String, :index => true
|
18
|
+
attribute :age, Integer
|
19
|
+
end
|
20
|
+
|
21
|
+
let(:db_uri) { 'datomic:mem://hello' }
|
22
|
+
|
23
|
+
it "can connect to a Datomic database" do
|
24
|
+
subject.connect(db_uri)
|
25
|
+
subject.connection.should be_a(Java::DatomicPeer::LocalConnection)
|
26
|
+
end
|
27
|
+
|
28
|
+
it_behaves_like "persistence API" do
|
29
|
+
let(:model_class) { Rat }
|
30
|
+
|
31
|
+
before(:all) do
|
32
|
+
subject.connect(db_uri)
|
33
|
+
Diametric::Persistence::Peer.create_schemas
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|