datomic-flare 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,206 @@
1
+ ## Flare DSL
2
+
3
+ It provides a Ruby-familiar approach to working with Datomic. It brings Ruby’s conventions and idioms while preserving Datomic’s data-first principles and terminology.
4
+
5
+ This approach should be cozy to those who are familiar with Ruby.
6
+
7
+ Learn more about Ruby and The Rails Doctrine:
8
+
9
+ - [About Ruby](https://www.ruby-lang.org/en/about/)
10
+ - [The Rails Doctrine](https://rubyonrails.org/doctrine)
11
+
12
+ ### Creating a Database
13
+
14
+ ```ruby:runnable
15
+ client.dsl.create_database!('radioactive')
16
+ ```
17
+
18
+ ```ruby:placeholder
19
+ ```
20
+
21
+ ### Deleting a Database
22
+
23
+ ```ruby:runnable
24
+ client.dsl.destroy_database!('radioactive')
25
+ ```
26
+
27
+ ```ruby:placeholder
28
+ ```
29
+
30
+ ### Listing Databases
31
+
32
+ ```ruby
33
+ client.dsl.databases
34
+ ```
35
+
36
+ ```ruby
37
+ ['my-datomic-database']
38
+ ```
39
+
40
+ ### Transacting Schema
41
+
42
+ Like `CREATE TABLE` in SQL databases or defining document or record structures in other databases.
43
+
44
+ ```ruby:runnable
45
+ client.dsl.transact_schema!(
46
+ {
47
+ book: {
48
+ title: { type: :string, doc: 'The title of the book.' },
49
+ genre: { type: :string, doc: 'The genre of the book.' },
50
+ published_at_year: { type: :long, doc: 'The year the book was first published.' }
51
+ }
52
+ })
53
+ ```
54
+
55
+ ```ruby:placeholder
56
+ ```
57
+
58
+ ### Checking Schema
59
+
60
+ Like `SHOW COLUMNS FROM` in SQL databases or checking document or record structures in other databases.
61
+
62
+ ```ruby:runnable
63
+ client.dsl.schema
64
+ ```
65
+
66
+ ```ruby:placeholder
67
+
68
+ ```
69
+
70
+ ### Asserting Facts
71
+
72
+ Like `INSERT INTO` in SQL databases or creating a new document or record in other databases.
73
+
74
+ ```ruby:runnable
75
+ client.dsl.assert_into!(
76
+ :book,
77
+ { title: 'Pride and Prejudice',
78
+ genre: 'Romance',
79
+ published_at_year: 1813 }
80
+ )
81
+ ```
82
+
83
+ ```ruby:placeholder
84
+ ```
85
+
86
+ ```ruby:runnable
87
+ client.dsl.assert_into!(
88
+ :book,
89
+ [{ title: 'Near to the Wild Heart',
90
+ genre: 'Novel',
91
+ published_at_year: 1943 },
92
+ { title: 'A Study in Scarlet',
93
+ genre: 'Detective',
94
+ published_at_year: 1887 },
95
+ { title: 'The Tell-Tale Heart',
96
+ genre: 'Horror',
97
+ published_at_year: 1843 }]
98
+ )
99
+ ```
100
+
101
+ ```ruby:state
102
+ state[:wild_heart_entity_id] = result[0]
103
+ state[:scarlet_entity_id] = result[1]
104
+ ```
105
+
106
+ ```ruby:placeholder
107
+ ```
108
+
109
+ ### Reading Data by Entity
110
+
111
+ Like `SELECT` in SQL databases or querying documents or records in other databases.
112
+
113
+ ```ruby:runnable/render
114
+ client.dsl.find_by_entity_id({{ state.wild_heart_entity_id }})
115
+ ```
116
+
117
+ ```ruby:placeholder
118
+ ```
119
+
120
+ ### Reading Data by Querying
121
+
122
+ Like `SELECT` in SQL databases or querying documents or records in other databases.
123
+
124
+ ```ruby:runnable
125
+ client.dsl.query(
126
+ datalog: <<~EDN
127
+ [:find ?e ?title ?genre ?year
128
+ :where [?e :book/title ?title]
129
+ [?e :book/genre ?genre]
130
+ [?e :book/published_at_year ?year]]
131
+ EDN
132
+ )
133
+ ```
134
+
135
+ ```ruby:placeholder
136
+ ```
137
+
138
+ ```ruby:runnable
139
+ client.dsl.query(
140
+ params: ['The Tell-Tale Heart'],
141
+ datalog: <<~EDN
142
+ [:find ?e ?title ?genre ?year
143
+ :in $ ?title
144
+ :where [?e :book/title ?title]
145
+ [?e :book/genre ?genre]
146
+ [?e :book/published_at_year ?year]]
147
+ EDN
148
+ )
149
+ ```
150
+
151
+ ```ruby:state
152
+ state[:tale_heart_entity_id] = result[0][0]
153
+ ```
154
+
155
+ ```ruby:placeholder
156
+ ```
157
+
158
+ ### Accumulating Facts
159
+
160
+ Like `UPDATE` in SQL databases or updating documents or records in other databases. However, Datomic never updates data. It is an immutable database that only accumulates new facts or retracts past facts.
161
+
162
+ ```ruby:runnable/render
163
+ client.dsl.assert_into!(
164
+ :book, { _id: {{ state.tale_heart_entity_id }}, genre: 'Gothic' }
165
+ )
166
+ ```
167
+
168
+ ```ruby:placeholder
169
+ ```
170
+
171
+ ### Retracting Facts
172
+
173
+ Like `DELETE` in SQL databases or deleting documents or records in other databases. However, Datomic never deletes data. It is an immutable database that only accumulates new facts or retracts past facts.
174
+
175
+ Retract the value of an attribute:
176
+
177
+ ```ruby:runnable/render
178
+ client.dsl.retract_from!(
179
+ :book, { _id: {{ state.tale_heart_entity_id }}, genre: 'Gothic' }
180
+ )
181
+ ```
182
+
183
+ ```ruby:placeholder
184
+ ```
185
+
186
+ Retract an attribute:
187
+
188
+ ```ruby:runnable/render
189
+ client.dsl.retract_from!(
190
+ :book, { _id: {{ state.wild_heart_entity_id }}, genre: nil }
191
+ )
192
+ ```
193
+
194
+ ```ruby:placeholder
195
+ ```
196
+
197
+ Retract an entity:
198
+
199
+ ```ruby:runnable/render
200
+ client.dsl.retract_from!(
201
+ :book, { _id: {{ state.scarlet_entity_id }} }
202
+ )
203
+ ```
204
+
205
+ ```ruby:placeholder
206
+ ```
data/helpers/h.rb ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flare
4
+ module H
5
+ def self.symbolize_keys(structure)
6
+ result = {}
7
+
8
+ structure.each do |key, value|
9
+ string_key = key.to_sym
10
+
11
+ result[string_key] = value.is_a?(Hash) ? symbolize_keys(value) : value
12
+ end
13
+
14
+ result
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flare
4
+ module DangerousOverrideLogic
5
+ CONNECTION_DATABASE_KEYS = [:name].freeze
6
+
7
+ DATABASE_KEYS = %i[name latest as_of].freeze
8
+
9
+ def self.apply_dangerous_overrides_to_payload(path, overrides, payload)
10
+ case path
11
+ when 'datomic/create-database', 'datomic/delete-database',
12
+ 'datomic/get-database-names', 'datomic/list-databases',
13
+ 'meta',
14
+ 'datomic/_debug/as-peer/create-database',
15
+ 'datomic/_debug/as-peer/delete-database'
16
+ payload
17
+ when 'datomic/transact'
18
+ inject_connection_overrides(overrides, payload)
19
+ when 'datomic/entity', 'datomic/datoms'
20
+ inject_database_overrides(overrides, payload)
21
+ when 'datomic/q'
22
+ inject_database_overrides_into_inputs(overrides, payload)
23
+ else
24
+ raise "Unexpected path: '#{path}'"
25
+ end
26
+ end
27
+
28
+ def self.inject_connection_overrides(overrides, payload)
29
+ if !overrides.key?(:database) || overrides[:database].slice(
30
+ *CONNECTION_DATABASE_KEYS
31
+ ).empty?
32
+ return payload
33
+ end
34
+
35
+ payload[:connection] = {} unless payload.key?(:connection)
36
+
37
+ payload[:connection][:database] = {} unless payload[:connection].key?(:database)
38
+
39
+ payload[:connection][:database] = payload[:connection][:database].merge(
40
+ overrides[:database].slice(*CONNECTION_DATABASE_KEYS)
41
+ )
42
+
43
+ payload
44
+ end
45
+
46
+ def self.inject_overrides_into_input(overrides, input)
47
+ input_has_latest = input[:database].key?(:latest)
48
+
49
+ input_has_as_of = input[:database].key?(:as_of)
50
+
51
+ overrides_has_as_of = overrides[:database].key?(:as_of)
52
+ overrides_has_latest = overrides[:database].key?(:latest)
53
+
54
+ input[:database] = input[:database].except(:latest) if input_has_latest && overrides_has_as_of
55
+
56
+ input[:database] = input[:database].except(:as_of) if input_has_as_of && overrides_has_latest
57
+
58
+ input[:database] = input[:database].merge(
59
+ overrides[:database].slice(*DATABASE_KEYS)
60
+ )
61
+
62
+ input
63
+ end
64
+
65
+ def self.inject_database_overrides_into_inputs(overrides, payload)
66
+ if !overrides.key?(:database) || overrides[:database].slice(
67
+ *DATABASE_KEYS
68
+ ).empty?
69
+ return payload
70
+ end
71
+
72
+ payload[:inputs] = {} unless payload.key?(:inputs)
73
+
74
+ index_for_database = payload[:inputs].index do |input|
75
+ input.key?(:database)
76
+ end
77
+
78
+ if index_for_database.nil?
79
+ require 'pry'
80
+ binding.pry
81
+ end
82
+
83
+ payload[:inputs][index_for_database] = inject_overrides_into_input(
84
+ overrides,
85
+ payload[:inputs][index_for_database]
86
+ )
87
+
88
+ payload
89
+ end
90
+
91
+ def self.inject_database_overrides(overrides, payload)
92
+ if !overrides.key?(:database) || overrides[:database].slice(
93
+ *DATABASE_KEYS
94
+ ).empty?
95
+ return payload
96
+ end
97
+
98
+ payload[:database] = {} unless payload.key?(:database)
99
+
100
+ payload[:database] = inject_overrides_into_input(
101
+ overrides,
102
+ { database: payload[:database] }
103
+ )[:database]
104
+
105
+ payload
106
+ end
107
+ end
108
+ end
data/logic/querying.rb ADDED
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Flare
4
+ module QueryingLogic
5
+ def self.entity_to_dsl(entity)
6
+ namespace = entity.keys.find { |key| key != ':db/id' }
7
+
8
+ return nil if namespace.nil?
9
+
10
+ namespace = namespace.split('/').first.sub(/^:/, '').to_sym
11
+
12
+ {
13
+ namespace => keys_to_dsl(entity)
14
+ }
15
+ end
16
+
17
+ def self.keys_to_dsl(entity)
18
+ result = {}
19
+
20
+ entity.each do |key, value|
21
+ # TODO: Is this correct? Should the 'id' exist?
22
+ dsl_key = if [':db/id', 'id'].include?(key)
23
+ :_id
24
+ else
25
+ key.split('/').last.to_sym
26
+ end
27
+
28
+ result[dsl_key] = value.is_a?(Hash) ? keys_to_dsl(value) : value
29
+ end
30
+
31
+ result
32
+ end
33
+ end
34
+ end
data/logic/schema.rb ADDED
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../helpers/h'
4
+
5
+ require_relative 'types'
6
+
7
+ module Flare
8
+ module SchemaLogic
9
+ QUERY = <<~EDN
10
+ [:find
11
+ ?e ?ident ?value_type ?cardinality ?doc
12
+ ?unique ?index ?no_history
13
+ :in $
14
+ :where
15
+ [?e :db/ident ?ident]
16
+
17
+ [?e :db/valueType ?value_type_id]
18
+ [?value_type_id :db/ident ?value_type]
19
+
20
+ [?e :db/cardinality ?cardinality_id]
21
+ [?cardinality_id :db/ident ?cardinality]
22
+
23
+ [(get-else $ ?e :db/doc "") ?doc]
24
+
25
+ [(get-else $ ?e :db/unique -1) ?unique_id]
26
+ [(get-else $ ?unique_id :db/ident false) ?unique]
27
+
28
+ [(get-else $ ?e :db/index false) ?index]
29
+ [(get-else $ ?e :db/noHistory false) ?no_history]]
30
+ EDN
31
+
32
+ NON_SCHEMA_NAMESPACES = %w[
33
+ db
34
+ db.alter db.attr db.bootstrap db.cardinality db.entity db.excise
35
+ db.fn db.install db.lang db.part db.sys db.type db.unique
36
+ fressian
37
+ ].freeze
38
+
39
+ def self.specification_to_edn(specification)
40
+ edn_schema = specification.flat_map do |namespace, attributes|
41
+ attributes.map.with_index do |(attribute, options), i|
42
+ fields = [
43
+ "#{i.zero? ? '' : ' '}{:db/ident :#{namespace}/#{attribute}",
44
+ " :db/valueType #{TypesLogic.ruby_to_datomic_type(options[:type])}",
45
+ " :db/cardinality #{TypesLogic.ruby_to_datomic_cardinality(options[:cardinality] || :one)}"
46
+ ]
47
+
48
+ fields << " :db/doc \"#{options[:doc]}\"" if options[:doc]
49
+ fields << " :db/unique #{TypesLogic.ruby_to_datomic_unique(options[:unique])}" if options[:unique]
50
+ fields << ' :db/index true' if options[:index]
51
+ fields << ' :db/noHistory true' if options[:history] == false
52
+
53
+ fields[fields.size - 1] = "#{fields.last}}"
54
+
55
+ fields.join("\n")
56
+ end
57
+ end.join("\n\n")
58
+
59
+ "[#{edn_schema}]"
60
+ end
61
+
62
+ def self.datoms_to_specification(datoms)
63
+ specification = {}
64
+
65
+ datoms.filter do |datom|
66
+ !NON_SCHEMA_NAMESPACES.include?(datom[1].split('/').first)
67
+ end.each do |entry|
68
+ namespace, attribute = entry[1].split('/')
69
+ type = TypesLogic.datomic_to_ruby_type(entry[2])
70
+ cardinality = TypesLogic.datomic_to_ruby_cardinality(entry[3])
71
+ doc = entry[4].empty? ? nil : entry[4]
72
+
73
+ unique = entry[5] ? TypesLogic.datomic_to_ruby_unique(entry[5]) : false
74
+ indexed = entry[6]
75
+ no_history = entry[7]
76
+
77
+ specification[namespace] ||= {}
78
+ specification[namespace][attribute] = {
79
+ type:,
80
+ cardinality:,
81
+ doc:,
82
+ unique:,
83
+ index: indexed,
84
+ history: !no_history
85
+ }
86
+ end
87
+
88
+ H.symbolize_keys(specification)
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'types'
4
+
5
+ module Flare
6
+ module TransactingLogic
7
+ def self.retractions_to_edn(namespace, retractions)
8
+ edn = retractions.map do |retraction|
9
+ retraction_to_edn(namespace, retraction)
10
+ end
11
+
12
+ "[#{edn.join("\n ")}]"
13
+ end
14
+
15
+ def self.retraction_to_edn(namespace, retraction)
16
+ id = retraction[:_id]
17
+
18
+ attributes = retraction.except(:_id)
19
+
20
+ if attributes.empty?
21
+ # Built-In Transaction Functions
22
+ # https://docs.datomic.com/transactions/transaction-functions.html#built-in
23
+ "[:db/retractEntity #{id}]"
24
+ else
25
+ attributes.map do |attribute, value|
26
+ if value.nil?
27
+ "[:db/retract #{id} :#{namespace}/#{attribute}]"
28
+ else
29
+ "[:db/retract #{id} :#{namespace}/#{attribute} #{TypesLogic.to_datomic_value(value)}]"
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ def self.transactions_to_edn(namespace, transactions)
36
+ edn = transactions.map do |transaction|
37
+ attributes = transaction.map.with_index do |(attribute, value), i|
38
+ ident = if %i[_id _temporary_id].include?(attribute)
39
+ ':db/id'
40
+ else
41
+ ":#{namespace}/#{attribute}"
42
+ end
43
+
44
+ "#{i.zero? ? '' : ' '}#{ident} #{TypesLogic.to_datomic_value(value)}"
45
+ end.join("\n")
46
+
47
+ "{#{attributes}}"
48
+ end.join("\n ")
49
+
50
+ "[#{edn}]"
51
+ end
52
+ end
53
+ end
data/logic/types.rb ADDED
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require 'bigdecimal'
5
+
6
+ module Flare
7
+ module TypesLogic
8
+ def self.to_datomic_value(value)
9
+ case value
10
+ when String
11
+ to_datomic_string(value)
12
+ when Integer
13
+ value.to_s
14
+ when Float
15
+ value.to_s
16
+ when BigDecimal
17
+ # https://github.com/relevance/edn-ruby/blob/master/lib/edn/core_ext.rb#L25
18
+ "#{value.to_s('F')}M"
19
+ when Integer, Float, TrueClass, FalseClass
20
+ value.to_s
21
+ when Time, DateTime, Date
22
+ to_datomic_instant(value)
23
+ when Symbol
24
+ ":#{value}"
25
+ when Array
26
+ '[' + value.map { |v| to_datomic_value(v) }.join(' ') + ']'
27
+ when NilClass
28
+ 'nil'
29
+ when Hash
30
+ raise ArgumentError, "Missing :_id for reference: #{value.class}" unless value.key?(:_id)
31
+
32
+ "{:db/id #{value[:_id]}}"
33
+ else
34
+ raise ArgumentError, "Unsupported value type: #{value.class}"
35
+ end
36
+ end
37
+
38
+ def self.to_datomic_string(value)
39
+ # https://github.com/relevance/edn-ruby/blob/master/lib/edn/core_ext.rb#L36
40
+ array = value.chars.map do |ch|
41
+ if %w[" \\].include?(ch)
42
+ "\\#{ch}"
43
+ else
44
+ ch
45
+ end
46
+ end
47
+ "\"#{array.join}\""
48
+ end
49
+
50
+ def self.to_datomic_instant(value)
51
+ time = case value
52
+ when Time
53
+ value
54
+ when Date
55
+ value.to_time
56
+ end
57
+
58
+ "#inst \"#{time.utc.strftime('%Y-%m-%dT%H:%M:%S.%L%:z')}\""
59
+ end
60
+
61
+ def self.ruby_to_datomic_type(ruby_type)
62
+ case ruby_type
63
+ when :string then ':db.type/string'
64
+ when :long then ':db.type/long'
65
+ when :boolean then ':db.type/boolean'
66
+ when :double then ':db.type/double'
67
+ when :instant then ':db.type/instant'
68
+ when :keyword then ':db.type/keyword'
69
+ when :uuid then ':db.type/uuid'
70
+ when :ref then ':db.type/ref'
71
+ when :bigdec then ':db.type/bigdec'
72
+ when :bigint then ':db.type/bigint'
73
+ when :uri then ':db.type/uri'
74
+ else
75
+ raise ArgumentError, "Unknown type: #{ruby_type}"
76
+ end
77
+ end
78
+
79
+ def self.ruby_to_datomic_cardinality(cardinality)
80
+ case cardinality
81
+ when :one then ':db.cardinality/one'
82
+ when :many then ':db.cardinality/many'
83
+ else
84
+ raise ArgumentError, "Unknown cardinality: #{cardinality}"
85
+ end
86
+ end
87
+
88
+ def self.ruby_to_datomic_unique(unique_type)
89
+ case unique_type
90
+ when :identity then ':db.unique/identity'
91
+ when :value then ':db.unique/value'
92
+ when nil then nil
93
+ else
94
+ raise ArgumentError, "Unknown uniqueness constraint: #{unique_type}"
95
+ end
96
+ end
97
+
98
+ def self.datomic_to_ruby_type(datomic_type)
99
+ datomic_type = ":#{datomic_type}" unless datomic_type.start_with?(':')
100
+ case datomic_type
101
+ when ':db.type/bigdec' then :bigdec
102
+ when ':db.type/bigint' then :bigint
103
+ when ':db.type/boolean' then :boolean
104
+ when ':db.type/bytes' then :bytes
105
+ when ':db.type/double' then :double
106
+ when ':db.type/float' then :float
107
+ when ':db.type/instant' then :instant
108
+ when ':db.type/keyword' then :keyword
109
+ when ':db.type/long' then :long
110
+ when ':db.type/ref' then :ref
111
+ when ':db.type/string' then :string
112
+ when ':db.type/symbol' then :symbol
113
+ when ':db.type/tuple' then :tuple
114
+ when ':db.type/uuid' then :uuid
115
+ when ':db.type/uri' then :uri
116
+ else
117
+ raise ArgumentError, "Unknown Datomic type: #{datomic_type}"
118
+ end
119
+ end
120
+
121
+ def self.datomic_to_ruby_unique(datomic_unique)
122
+ datomic_unique = ":#{datomic_unique}" unless datomic_unique.start_with?(':')
123
+ case datomic_unique
124
+ when ':db.unique/value' then :value
125
+ when ':db.unique/identity' then :identity
126
+ else
127
+ raise ArgumentError, "Unknown Datomic uniqueness: #{datomic_unique}"
128
+ end
129
+ end
130
+
131
+ def self.datomic_to_ruby_cardinality(datomic_cardinality)
132
+ datomic_cardinality = ":#{datomic_cardinality}" unless datomic_cardinality.start_with?(':')
133
+ case datomic_cardinality
134
+ when ':db.cardinality/one' then :one
135
+ when ':db.cardinality/many' then :many
136
+ else
137
+ raise ArgumentError, "Unknown Datomic cardinality: #{datomic_cardinality}"
138
+ end
139
+ end
140
+ end
141
+ end
data/ports/cli.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dotenv/load'
4
+
5
+ require_relative '../controllers/documentation/generator'
6
+
7
+ module Flare
8
+ module CLI
9
+ def self.handle(command)
10
+ case command
11
+ when 'docs:generate'
12
+ Flare::Controllers::Documentation::Generator.handler
13
+ else
14
+ puts 'Invalid command.'
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+ if __FILE__ == $PROGRAM_NAME
21
+ if ARGV.empty?
22
+ puts 'No command provided.'
23
+ else
24
+ Flare::CLI.handle(ARGV[0])
25
+ end
26
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../../components/errors'
4
+
5
+ include Flare::Errors
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uuidx'
4
+
5
+ require_relative '../../static/gem'
6
+ require_relative '../../controllers/client'
7
+
8
+ module Flare
9
+ def self.new(...)
10
+ Controllers::Client.new(...)
11
+ end
12
+
13
+ def self.uuid
14
+ Uuidx
15
+ end
16
+
17
+ def self.version
18
+ Flare::GEM[:version]
19
+ end
20
+ end