terrestrial 0.3.0 → 0.5.0

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.
Files changed (66) hide show
  1. checksums.yaml +5 -5
  2. data/.ruby-version +1 -1
  3. data/Gemfile.lock +44 -53
  4. data/README.md +3 -6
  5. data/bin/test +1 -1
  6. data/features/env.rb +12 -2
  7. data/features/example.feature +23 -26
  8. data/lib/terrestrial.rb +31 -0
  9. data/lib/terrestrial/adapters/abstract_adapter.rb +6 -0
  10. data/lib/terrestrial/adapters/memory_adapter.rb +82 -6
  11. data/lib/terrestrial/adapters/sequel_postgres_adapter.rb +191 -0
  12. data/lib/terrestrial/configurations/conventional_association_configuration.rb +65 -35
  13. data/lib/terrestrial/configurations/conventional_configuration.rb +280 -124
  14. data/lib/terrestrial/configurations/mapping_config_options_proxy.rb +97 -0
  15. data/lib/terrestrial/deleted_record.rb +12 -8
  16. data/lib/terrestrial/dirty_map.rb +17 -9
  17. data/lib/terrestrial/functional_pipeline.rb +64 -0
  18. data/lib/terrestrial/inspection_string.rb +6 -1
  19. data/lib/terrestrial/lazy_object_proxy.rb +1 -0
  20. data/lib/terrestrial/many_to_many_association.rb +34 -20
  21. data/lib/terrestrial/many_to_one_association.rb +11 -3
  22. data/lib/terrestrial/one_to_many_association.rb +9 -0
  23. data/lib/terrestrial/public_conveniencies.rb +65 -82
  24. data/lib/terrestrial/record.rb +106 -0
  25. data/lib/terrestrial/relation_mapping.rb +43 -12
  26. data/lib/terrestrial/relational_store.rb +33 -11
  27. data/lib/terrestrial/upsert_record.rb +54 -0
  28. data/lib/terrestrial/version.rb +1 -1
  29. data/spec/automatic_timestamps_spec.rb +339 -0
  30. data/spec/changes_api_spec.rb +81 -0
  31. data/spec/config_override_spec.rb +28 -19
  32. data/spec/custom_serializers_spec.rb +3 -2
  33. data/spec/database_default_fields_spec.rb +213 -0
  34. data/spec/database_generated_id_spec.rb +291 -0
  35. data/spec/database_owned_fields_and_timestamps_spec.rb +200 -0
  36. data/spec/deletion_spec.rb +1 -1
  37. data/spec/error_handling/factory_error_handling_spec.rb +1 -4
  38. data/spec/error_handling/serialization_error_spec.rb +1 -4
  39. data/spec/error_handling/upsert_error_spec.rb +7 -11
  40. data/spec/graph_persistence_spec.rb +52 -18
  41. data/spec/ordered_association_spec.rb +10 -12
  42. data/spec/predefined_queries_spec.rb +14 -12
  43. data/spec/readme_examples_spec.rb +1 -1
  44. data/spec/sequel_query_efficiency_spec.rb +19 -16
  45. data/spec/spec_helper.rb +6 -1
  46. data/spec/support/blog_schema.rb +7 -3
  47. data/spec/support/object_graph_setup.rb +30 -39
  48. data/spec/support/object_store_setup.rb +16 -196
  49. data/spec/support/seed_data_setup.rb +15 -149
  50. data/spec/support/seed_records.rb +141 -0
  51. data/spec/support/sequel_test_support.rb +46 -13
  52. data/spec/terrestrial/abstract_record_spec.rb +138 -106
  53. data/spec/terrestrial/adapters/sequel_postgres_adapter_spec.rb +138 -0
  54. data/spec/terrestrial/deleted_record_spec.rb +0 -27
  55. data/spec/terrestrial/dirty_map_spec.rb +52 -77
  56. data/spec/terrestrial/functional_pipeline_spec.rb +153 -0
  57. data/spec/terrestrial/inspection_string_spec.rb +61 -0
  58. data/spec/terrestrial/upsert_record_spec.rb +29 -0
  59. data/terrestrial.gemspec +7 -8
  60. metadata +43 -40
  61. data/MissingFeatures.md +0 -64
  62. data/lib/terrestrial/abstract_record.rb +0 -99
  63. data/lib/terrestrial/association_loaders.rb +0 -52
  64. data/lib/terrestrial/upserted_record.rb +0 -15
  65. data/spec/terrestrial/public_conveniencies_spec.rb +0 -63
  66. data/spec/terrestrial/upserted_record_spec.rb +0 -59
@@ -0,0 +1,191 @@
1
+ require "forwardable"
2
+ require "terrestrial/adapters/abstract_adapter"
3
+
4
+ module Terrestrial
5
+ module WrapDelegate
6
+ def wrap_delegators(target_name, method_names)
7
+ method_names.each do |method_name|
8
+ define_method(method_name) do |*args, &block|
9
+ self.class.new(
10
+ send(target_name).public_send(method_name, *args, &block)
11
+ )
12
+ end
13
+ end
14
+ end
15
+ end
16
+
17
+ module SequelDatasetComparisonLiteralAppendPatch
18
+ def ===(other)
19
+ other.is_a?(Adapters::SequelPostgresAdapter::Dataset) or
20
+ super
21
+ end
22
+ end
23
+
24
+ Sequel::Dataset.extend(SequelDatasetComparisonLiteralAppendPatch)
25
+
26
+ module Adapters
27
+ class SequelPostgresAdapter
28
+ extend Forwardable
29
+ include Adapters::AbstractAdapter
30
+
31
+ def initialize(database)
32
+ @database = database
33
+ end
34
+
35
+ attr_reader :database
36
+ private :database
37
+
38
+ def_delegators :database, *[
39
+ :transaction,
40
+ :tables,
41
+ ]
42
+
43
+ def [](table_name)
44
+ Dataset.new(database[table_name])
45
+ end
46
+
47
+ def upsert(record)
48
+ row = perform_upsert_returning_row(record)
49
+ record.on_upsert(row)
50
+ nil
51
+ rescue Object => e
52
+ raise UpsertError.new(record.namespace, record.to_h, e)
53
+ end
54
+
55
+ def delete(record)
56
+ database[record.namespace].where(record.identity).delete
57
+ end
58
+
59
+ def changes_sql(record)
60
+ generate_upsert_sql(record)
61
+ rescue Object => e
62
+ raise UpsertError.new(record.namespace, record.to_h, e)
63
+ end
64
+
65
+ def conflict_fields(table_name)
66
+ primary_key(table_name)
67
+ end
68
+
69
+ def primary_key(table_name)
70
+ [database.primary_key(table_name)]
71
+ .compact
72
+ .map(&:to_sym)
73
+ end
74
+
75
+ def unique_indexes(table_name)
76
+ database.indexes(table_name).map { |_name, data|
77
+ data.fetch(:columns)
78
+ }
79
+ end
80
+
81
+ def relations
82
+ database.tables - [:schema_migrations]
83
+ end
84
+
85
+ def relation_fields(relation_name)
86
+ database[relation_name].columns
87
+ end
88
+
89
+ def schema(relation_name)
90
+ database.schema(relation_name)
91
+ end
92
+
93
+ private
94
+
95
+ def perform_upsert_returning_row(record)
96
+ sql = generate_upsert_sql(record)
97
+ result = database[sql]
98
+ .to_a
99
+ .fetch(0) { {} }
100
+ end
101
+
102
+ def generate_upsert_sql(record)
103
+ table_name = record.namespace
104
+ update_attributes = record.updatable? && record.updatable_attributes
105
+
106
+ primary_key_fields = primary_key(table_name)
107
+
108
+ missing_not_null_fields = database.schema(table_name)
109
+ .reject { |field_name, _| record.attributes.keys.include?(field_name) }
110
+ .select { |_field_name, properties|
111
+ allow_null = properties.fetch(:allow_null, true)
112
+ not_null = !allow_null
113
+ default = properties.fetch(:default, nil)
114
+ no_default = !default
115
+
116
+ not_null && no_default
117
+ }
118
+ .map(&:first)
119
+ .reject { |field_name| record.identity_fields.include?(field_name) }
120
+
121
+ missing_not_null_attrs = missing_not_null_fields
122
+ .map { |field_name| [field_name, database[table_name].select(field_name).where(record.identity)] }
123
+ .to_h
124
+
125
+ # TODO: investigate if failing to find a private key results in extra schema queries
126
+ if primary_key_fields.any?
127
+ if record.id?
128
+ conflict_fields = primary_key_fields
129
+ else
130
+ return database[table_name]
131
+ .returning(Sequel.lit("*"))
132
+ .insert_sql(record.insertable.merge(missing_not_null_attrs))
133
+ end
134
+ else
135
+ u_idxs = unique_indexes(table_name)
136
+ if u_idxs.any?
137
+ conflict_fields = u_idxs.first
138
+ end
139
+ end
140
+
141
+ upsert_args = { update: update_attributes }
142
+
143
+ if conflict_fields && conflict_fields.any?
144
+ upsert_args.merge!(target: conflict_fields)
145
+ end
146
+
147
+ # TODO: Use specific field list instead of Sequel.lit("*")
148
+ database[table_name]
149
+ .insert_conflict(**upsert_args)
150
+ .returning(Sequel.lit("*"))
151
+ .insert_sql(record.insertable.merge(missing_not_null_attrs))
152
+ end
153
+
154
+ class Dataset
155
+ extend Forwardable
156
+ extend WrapDelegate
157
+ include Enumerable
158
+
159
+ def initialize(dataset)
160
+ @dataset = dataset
161
+ end
162
+
163
+ attr_reader :dataset
164
+ private :dataset
165
+
166
+ wrap_delegators :dataset, [
167
+ :select,
168
+ :where,
169
+ :clone,
170
+ :order,
171
+ ]
172
+
173
+ def_delegators :dataset, *[
174
+ :empty?,
175
+ :delete,
176
+ :opts,
177
+ :sql,
178
+ :reverse,
179
+ ]
180
+
181
+ def cache_sql?
182
+ false
183
+ end
184
+
185
+ def each(&block)
186
+ dataset.each(&block)
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end
@@ -10,22 +10,22 @@ module Terrestrial
10
10
  require "terrestrial/lazy_object_proxy"
11
11
 
12
12
  class ConventionalAssociationConfiguration
13
- def initialize(mapping_name, mappings, datastore)
14
- @local_mapping_name = mapping_name
15
- @mappings = mappings
16
- @local_mapping = mappings.fetch(local_mapping_name)
13
+ def initialize(inflector, datastore, mapping_name, mappings)
14
+ @inflector = inflector
17
15
  @datastore = datastore
16
+ @target_mapping = mappings.fetch(mapping_name)
17
+ @mappings = mappings
18
18
  end
19
19
 
20
- attr_reader :local_mapping_name, :local_mapping, :mappings, :datastore
21
- private :local_mapping_name, :local_mapping, :mappings, :datastore
20
+ attr_reader :inflector, :datastore, :target_mapping, :mappings
21
+ private :inflector, :datastore, :target_mapping, :mappings
22
22
 
23
23
  DEFAULT = Module.new
24
24
 
25
25
  def has_many(association_name, key: DEFAULT, foreign_key: DEFAULT, mapping_name: DEFAULT, order_fields: DEFAULT, order_direction: DEFAULT)
26
26
  defaults = {
27
27
  mapping_name: association_name,
28
- foreign_key: [INFLECTOR.singularize(local_mapping_name), "_id"].join.to_sym,
28
+ foreign_key: [singular_name, "_id"].join.to_sym,
29
29
  key: :id,
30
30
  order_fields: [],
31
31
  order_direction: "ASC",
@@ -45,17 +45,14 @@ module Terrestrial
45
45
  associated_mapping_name = config.fetch(:mapping_name)
46
46
  associated_mapping = mappings.fetch(associated_mapping_name)
47
47
 
48
- local_mapping.add_association(
49
- association_name,
50
- has_many_mapper(**config)
51
- )
48
+ has_many_mapper(**config)
52
49
  end
53
50
 
54
51
  def belongs_to(association_name, key: DEFAULT, foreign_key: DEFAULT, mapping_name: DEFAULT)
55
52
  defaults = {
56
53
  key: :id,
57
54
  foreign_key: [association_name, "_id"].join.to_sym,
58
- mapping_name: INFLECTOR.pluralize(association_name).to_sym,
55
+ mapping_name: pluralize(association_name).to_sym,
59
56
  }
60
57
 
61
58
  specified = {
@@ -71,19 +68,17 @@ module Terrestrial
71
68
  associated_mapping_name = config.fetch(:mapping_name)
72
69
  associated_mapping = mappings.fetch(associated_mapping_name)
73
70
 
74
- local_mapping.add_association(
75
- association_name,
76
- belongs_to_mapper(**config)
77
- )
71
+ belongs_to_mapper(**config)
78
72
  end
79
73
 
80
- def has_many_through(association_name, key: DEFAULT, foreign_key: DEFAULT, mapping_name: DEFAULT, through_mapping_name: DEFAULT, association_key: DEFAULT, association_foreign_key: DEFAULT, order_fields: DEFAULT, order_direction: DEFAULT)
74
+ def has_many_through(association_name, key: DEFAULT, foreign_key: DEFAULT, mapping_name: DEFAULT, through_table_name: DEFAULT, association_key: DEFAULT, association_foreign_key: DEFAULT, order_fields: DEFAULT, order_direction: DEFAULT)
75
+ # TODO: join_dataset as mutually exclusive option with join_table_name
81
76
  defaults = {
82
77
  mapping_name: association_name,
83
78
  key: :id,
84
79
  association_key: :id,
85
- foreign_key: [INFLECTOR.singularize(local_mapping_name), "_id"].join.to_sym,
86
- association_foreign_key: [INFLECTOR.singularize(association_name), "_id"].join.to_sym,
80
+ foreign_key: [singular_name, "_id"].join.to_sym,
81
+ association_foreign_key: [singularize(association_name), "_id"].join.to_sym,
87
82
  order_fields: [],
88
83
  order_direction: "ASC",
89
84
  }
@@ -101,26 +96,30 @@ module Terrestrial
101
96
  }
102
97
 
103
98
  config = defaults.merge(specified)
99
+
104
100
  associated_mapping = mappings.fetch(config.fetch(:mapping_name))
101
+ default_through_table_name = [associated_mapping.name, target_mapping.name].sort.join("_to_").to_sym
105
102
 
106
- if through_mapping_name == DEFAULT
107
- through_mapping_name = [
108
- associated_mapping.name,
109
- local_mapping.name,
110
- ].sort.join("_to_").to_sym
103
+ if through_table_name == DEFAULT
104
+ through_table_name = default_through_table_name
111
105
  end
112
106
 
113
- join_table_name = mappings.fetch(through_mapping_name).namespace
114
- config = config
115
- .merge(
116
- through_mapping_name: through_mapping_name,
117
- through_dataset: datastore[join_table_name.to_sym],
118
- )
107
+ join_mapping = create_virtual_mapping(
108
+ default_mapping_name: default_through_table_name,
109
+ namespace: through_table_name,
110
+ primary_key: [config[:foreign_key], config[:association_foreign_key]],
111
+ )
112
+
113
+ mappings[join_mapping.name] = join_mapping
119
114
 
120
- local_mapping.add_association(
121
- association_name,
122
- has_many_through_mapper(**config)
115
+ join_dataset = datastore[through_table_name.to_sym]
116
+
117
+ config = config.merge(
118
+ join_mapping_name: join_mapping.name,
119
+ join_dataset: join_dataset,
123
120
  )
121
+
122
+ has_many_through_mapper(**config)
124
123
  end
125
124
 
126
125
  private
@@ -144,10 +143,11 @@ module Terrestrial
144
143
  )
145
144
  end
146
145
 
147
- def has_many_through_mapper(mapping_name:, key:, foreign_key:, association_key:, association_foreign_key:, through_mapping_name:, through_dataset:, order_fields:, order_direction:)
146
+ def has_many_through_mapper(mapping_name:, key:, foreign_key:, association_key:, association_foreign_key:, join_mapping_name:, join_dataset:, order_fields:, order_direction:)
148
147
  ManyToManyAssociation.new(
149
148
  mapping_name: mapping_name,
150
- join_mapping_name: through_mapping_name,
149
+ join_mapping_name: join_mapping_name,
150
+ join_dataset: join_dataset, # TODO: this dataset is not used
151
151
  key: key,
152
152
  foreign_key: foreign_key,
153
153
  association_key: association_key,
@@ -157,6 +157,24 @@ module Terrestrial
157
157
  )
158
158
  end
159
159
 
160
+ def create_virtual_mapping(default_mapping_name:, namespace:, primary_key:)
161
+ mapping_name = "__generated_virtual_mapping_#{default_mapping_name}"
162
+
163
+ RelationMapping.new(
164
+ name: mapping_name,
165
+ namespace: namespace,
166
+ primary_key: primary_key,
167
+ factory: ->(*_) { },
168
+ serializer: :to_h.to_proc,
169
+ fields: [],
170
+ associations: [],
171
+ subsets: [],
172
+ database_owned_fields: [],
173
+ database_default_fields: [],
174
+ observers: [],
175
+ )
176
+ end
177
+
160
178
  def single_object_proxy_factory
161
179
  ->(query:, loader:, preloaded_data:) {
162
180
  LazyObjectProxy.new(
@@ -181,6 +199,18 @@ module Terrestrial
181
199
  def query_order(fields, direction)
182
200
  QueryOrder.new(fields: fields, direction: direction)
183
201
  end
202
+
203
+ def singular_name
204
+ inflector.singularize(target_mapping.name)
205
+ end
206
+
207
+ def singularize(string)
208
+ inflector.singularize(string)
209
+ end
210
+
211
+ def pluralize(string)
212
+ inflector.pluralize(string)
213
+ end
184
214
  end
185
215
  end
186
216
  end
@@ -1,113 +1,57 @@
1
+ require "fetchable"
2
+
3
+ require "terrestrial/configurations/mapping_config_options_proxy"
1
4
  require "terrestrial/configurations/conventional_association_configuration"
2
5
  require "terrestrial/relation_mapping"
3
6
  require "terrestrial/subset_queries_proxy"
4
7
  require "terrestrial/struct_factory"
8
+ require "sequel/model/inflections"
5
9
 
6
10
  module Terrestrial
7
11
  module Configurations
8
- require "active_support/inflector"
12
+
9
13
  class Inflector
10
- include ActiveSupport::Inflector
14
+ include Sequel::Inflections
15
+
16
+ def classify(string)
17
+ singularize(camelize(string))
18
+ end
19
+
20
+ public :singularize
21
+ public :pluralize
11
22
  end
12
23
 
13
- INFLECTOR = Inflector.new
24
+ Default = Module.new
14
25
 
15
- require "fetchable"
16
26
  class ConventionalConfiguration
27
+ include Enumerable
17
28
  include Fetchable
18
29
 
19
- def initialize(datastore)
30
+ def initialize(datastore:, clock:, dirty_map:, identity_map:, inflector: Inflector.new)
20
31
  @datastore = datastore
32
+ @inflector = inflector
33
+ @dirty_map = dirty_map
34
+ @identity_map = identity_map
35
+ @clock = clock
36
+
21
37
  @overrides = {}
22
38
  @subset_queries = {}
23
39
  @associations_by_mapping = {}
24
40
  end
25
41
 
26
- attr_reader :datastore, :mappings
27
- private :datastore, :mappings
28
-
29
- def [](mapping_name)
30
- mappings[mapping_name]
31
- end
32
-
33
- include Enumerable
34
- def each(&block)
35
- mappings.each(&block)
36
- end
42
+ attr_reader :overrides, :clock, :identity_map, :dirty_map, :datastore, :mappings, :inflector
37
43
 
38
44
  def setup_mapping(mapping_name, &block)
39
45
  @associations_by_mapping[mapping_name] ||= []
46
+ @overrides[mapping_name] ||= {}
40
47
 
41
- block.call(
42
- RelationConfigOptionsProxy.new(
43
- method(:add_override).to_proc.curry.call(mapping_name),
44
- method(:add_subset).to_proc.curry.call(mapping_name),
45
- @associations_by_mapping.fetch(mapping_name),
46
- )
47
- ) if block
48
-
49
- # TODO: more madness in this silly config this, kill it with fire.
50
- explicit_settings = @overrides[mapping_name] ||= {}
51
- explicit_settings[:factory] ||= raise_if_not_found_factory(mapping_name)
48
+ block && block.call(
49
+ MappingConfigOptionsProxy.new(self, mapping_name)
50
+ )
52
51
 
53
52
  self
54
53
  end
55
54
 
56
- private
57
-
58
- class RelationConfigOptionsProxy
59
- def initialize(config_override, subset_adder, association_register)
60
- @config_override = config_override
61
- @subset_adder = subset_adder
62
- @association_register = association_register
63
- end
64
-
65
- def relation_name(name)
66
- @config_override.call(relation_name: name)
67
- end
68
- alias_method :table_name, :relation_name
69
-
70
- def subset(subset_name, &block)
71
- @subset_adder.call(subset_name, block)
72
- end
73
-
74
- def has_many(*args)
75
- @association_register.push([:has_many, args])
76
- end
77
-
78
- def has_many_through(*args)
79
- @association_register.push([:has_many_through, args])
80
- end
81
-
82
- def belongs_to(*args)
83
- @association_register.push([:belongs_to, args])
84
- end
85
-
86
- def fields(field_names)
87
- @config_override.call(fields: field_names)
88
- end
89
-
90
- def primary_key(field_names)
91
- @config_override.call(primary_key: field_names)
92
- end
93
-
94
- def factory(callable)
95
- @config_override.call(factory: callable)
96
- end
97
-
98
- def class(entity_class)
99
- @config_override.call('class': entity_class)
100
- end
101
-
102
- def class_name(class_name)
103
- @config_override.call(class_name: class_name)
104
- end
105
-
106
- def serializer(serializer_func)
107
- @config_override.call(serializer: serializer_func)
108
- end
109
- end
110
-
111
55
  def mappings
112
56
  @mappings ||= generate_mappings
113
57
  end
@@ -127,11 +71,18 @@ module Terrestrial
127
71
  )
128
72
  end
129
73
 
74
+ def add_association(mapping_name, type, options)
75
+ @associations_by_mapping.fetch(mapping_name).push([type, options])
76
+ end
77
+
78
+ private
79
+
130
80
  def association_configurator(mappings, mapping_name)
131
81
  ConventionalAssociationConfiguration.new(
82
+ inflector,
83
+ datastore,
132
84
  mapping_name,
133
85
  mappings,
134
- datastore,
135
86
  )
136
87
  end
137
88
 
@@ -140,17 +91,13 @@ module Terrestrial
140
91
  [mapping_name, {relation_name: mapping_name}.merge(consolidate_overrides(overrides))]
141
92
  }
142
93
 
143
- table_mappings = (tables - @overrides.keys).map { |table_name|
144
- [table_name, overrides_for_table(table_name)]
145
- }
146
-
147
94
  Hash[
148
- (table_mappings + custom_mappings).map { |(mapping_name, overrides)|
95
+ (custom_mappings).map { |(mapping_name, overrides)|
149
96
  table_name = overrides.fetch(:relation_name) { raise no_table_error(mapping_name) }
150
97
 
151
98
  [
152
99
  mapping_name,
153
- mapping(
100
+ build_mapping(
154
101
  **default_mapping_args(table_name, mapping_name).merge(overrides)
155
102
  ),
156
103
  ]
@@ -166,10 +113,16 @@ module Terrestrial
166
113
  # as a dependency and then sends mutating messages to them.
167
114
  # This mutation based approach was originally a spike but now just
168
115
  # seems totally bananas!
169
- @associations_by_mapping.each do |mapping_name, associations|
170
- associations.each do |(assoc_type, assoc_args)|
171
- association_configurator(mappings, mapping_name)
116
+ @associations_by_mapping.each do |mapping_name, association_data|
117
+ association_data.each do |(assoc_type, assoc_args)|
118
+ association = association_configurator(mappings, mapping_name)
172
119
  .public_send(assoc_type, *assoc_args)
120
+
121
+ name = assoc_args.fetch(0)
122
+ mappings.fetch(mapping_name).add_association(name, association)
123
+ associated_mapping = mappings.fetch(association.mapping_name)
124
+
125
+ associated_mapping.register_foreign_key(association.outgoing_foreign_keys)
173
126
  end
174
127
  end
175
128
  end
@@ -178,9 +131,17 @@ module Terrestrial
178
131
  {
179
132
  name: mapping_name,
180
133
  relation_name: table_name,
181
- fields: get_fields(table_name),
134
+ fields: all_available_fields(table_name),
182
135
  primary_key: get_primary_key(table_name),
183
- factory: ok_if_it_doesnt_exist_factory(mapping_name),
136
+ use_database_id: false,
137
+ database_id_setter: nil,
138
+ database_owned_fields_setter_map: {},
139
+ database_default_fields_setter_map: {},
140
+ updated_at_field: nil,
141
+ updated_at_setter: nil,
142
+ created_at_field: nil,
143
+ created_at_setter: nil,
144
+ factory: ok_if_class_is_not_defined_factory(mapping_name),
184
145
  serializer: hash_coercion_serializer,
185
146
  associations: {},
186
147
  subsets: subset_queries_proxy(@subset_queries.fetch(mapping_name, {})),
@@ -213,31 +174,70 @@ module Terrestrial
213
174
  new_opts
214
175
  end
215
176
 
216
- def get_fields(table_name)
217
- datastore[table_name].columns
177
+ def all_available_fields(relation_name)
178
+ datastore.relation_fields(relation_name)
218
179
  end
219
180
 
220
181
  def get_primary_key(table_name)
221
182
  datastore.schema(table_name)
222
183
  .select { |field_name, properties|
223
- properties.fetch(:primary_key)
184
+ properties.fetch(:primary_key, false)
224
185
  }
225
186
  .map { |field_name, _| field_name }
226
187
  end
227
188
 
189
+ # TODO: inconsisent naming
228
190
  def tables
229
- (datastore.tables - [:schema_migrations])
191
+ datastore.relations
230
192
  end
231
193
 
232
194
  def hash_coercion_serializer
233
- ->(o) { o.to_h }
195
+ HashCoercionSerializer.new
234
196
  end
235
197
 
236
198
  def subset_queries_proxy(subset_map)
237
199
  SubsetQueriesProxy.new(subset_map)
238
200
  end
239
201
 
240
- def mapping(name:, relation_name:, primary_key:, factory:, serializer:, fields:, associations:, subsets:)
202
+ def build_mapping(name:, relation_name:, primary_key:, use_database_id:, database_id_setter:, database_owned_fields_setter_map:, database_default_fields_setter_map:, updated_at_field:, updated_at_setter:, created_at_field:, created_at_setter:, factory:, serializer:, fields:, associations:, subsets:)
203
+ if use_database_id
204
+ database_id_setter ||= object_setter(primary_key.first)
205
+ end
206
+ if created_at_field
207
+ created_at_field = created_at_field == Default ? :created_at : created_at_field
208
+ created_at_setter ||= object_setter(created_at_field)
209
+ end
210
+ if updated_at_field
211
+ updated_at_field = updated_at_field == Default ? :updated_at : updated_at_field
212
+ updated_at_setter ||= object_setter(updated_at_field)
213
+ end
214
+
215
+ timestamp_observer = TimestampObserver.new(
216
+ clock,
217
+ dirty_map,
218
+ created_at_field,
219
+ created_at_setter,
220
+ updated_at_field,
221
+ updated_at_setter,
222
+ )
223
+
224
+ database_owned_field_observers = database_owned_fields_setter_map.map { |field, setter|
225
+ setter ||= ->(object, value) { object.send("#{field}=", value) }
226
+ ArbitraryDatabaseOwnedValueObserver.new(field, setter)
227
+ }
228
+
229
+ database_default_field_observers = database_default_fields_setter_map.map { |field, setter|
230
+ setter ||= ->(object, value) { object.send("#{field}=", value) }
231
+ ArbitraryDatabaseDefaultValueObserver.new(field, setter)
232
+ }
233
+
234
+ observers = [
235
+ use_database_id && DatabaseIDObserver.new(database_id_setter),
236
+ (created_at_field || updated_at_field) && timestamp_observer,
237
+ *database_owned_field_observers,
238
+ *database_default_field_observers,
239
+ ].select(&:itself)
240
+
241
241
  RelationMapping.new(
242
242
  name: name,
243
243
  namespace: relation_name,
@@ -245,39 +245,29 @@ module Terrestrial
245
245
  factory: factory,
246
246
  serializer: serializer,
247
247
  fields: fields,
248
+ database_owned_fields: database_owned_fields_setter_map.keys,
249
+ database_default_fields: database_default_fields_setter_map.keys,
248
250
  associations: associations,
249
251
  subsets: subsets,
252
+ observers: observers,
250
253
  )
251
254
  end
252
255
 
253
- FactoryNotFoundError = Class.new(StandardError) do
254
- def initialize(specified)
255
- @specified = specified
256
- end
257
-
258
- def message
259
- "Could not find factory for #{@specified}"
260
- end
256
+ def object_setter(field_name)
257
+ SetterMethodCaller.new(field_name)
261
258
  end
262
259
 
263
- TableNameNotSpecifiedError = Class.new(StandardError) do
264
- def initialize(mapping_name)
265
- @message = "Error defining custom mapping `#{mapping_name}`." +
266
- " You must provide the `table_name` configuration option."
267
- end
260
+ def simple_setter_method_caller(primary_key)
261
+ SetterMethodCaller.new(primary_key.first)
268
262
  end
269
263
 
270
- def raise_if_not_found_factory(name)
271
- ->(attrs) {
272
- class_to_factory(string_to_class(name)).call(attrs)
273
- }
264
+ def class_with_same_name_as_mapping_factory(name)
265
+ target_class = string_to_class(name)
266
+ ClassFactory.new(target_class)
274
267
  end
275
268
 
276
- def ok_if_it_doesnt_exist_factory(name)
277
- ->(attrs) {
278
- factory = class_to_factory(string_to_class(name)) rescue nil
279
- factory && factory.call(attrs)
280
- }
269
+ def ok_if_class_is_not_defined_factory(name)
270
+ LazyClassLookupFactory.new(class_name(name))
281
271
  end
282
272
 
283
273
  def class_to_factory(klass)
@@ -289,14 +279,180 @@ module Terrestrial
289
279
  end
290
280
 
291
281
  def string_to_class(string)
292
- klass_name = INFLECTOR.classify(string)
282
+ Object.const_get(class_name(string))
283
+ end
293
284
 
294
- Object.const_get(klass_name)
285
+ def class_name(name)
286
+ inflector.classify(name)
295
287
  end
296
288
 
297
289
  def no_table_error(table_name)
298
290
  TableNameNotSpecifiedError.new(table_name)
299
291
  end
292
+
293
+ class ClassFactory
294
+ def initialize(target_class)
295
+ @target_class = target_class
296
+ end
297
+
298
+ def call(attrs)
299
+ @target_class.new(attrs)
300
+ end
301
+ end
302
+
303
+ class LazyClassLookupFactory
304
+ def initialize(class_name)
305
+ @class_name = class_name
306
+ end
307
+
308
+ def call(attrs)
309
+ target_class && target_class.new(attrs)
310
+ end
311
+
312
+ private
313
+
314
+ def target_class
315
+ @target_class ||= Object.const_get(@class_name)
316
+ end
317
+ end
318
+
319
+ class HashCoercionSerializer
320
+ def call(object)
321
+ object.to_h
322
+ end
323
+ end
324
+
325
+ class TableNameNotSpecifiedError < StandardError
326
+ def initialize(mapping_name)
327
+ @message = "Error defining custom mapping `#{mapping_name}`." \
328
+ " You must provide the `table_name` configuration option."
329
+ end
330
+ end
331
+
332
+ class DatabaseIDObserver
333
+ def initialize(setter)
334
+ @setter = setter
335
+ end
336
+
337
+ attr_reader :setter
338
+ private :setter
339
+
340
+ def post_serialize(mapping, object, record)
341
+ add_database_id_container!(record)
342
+ end
343
+
344
+ def post_save(mapping, object, record, new_record)
345
+ if !record.id?
346
+ new_id = new_record.identity_values.first
347
+ record.identity_values.first.value = new_id
348
+ setter.call(object, new_id)
349
+ end
350
+ end
351
+
352
+ private
353
+
354
+ def add_database_id_container!(record)
355
+ if !record.id?
356
+ record.set_id(database_id_container)
357
+ end
358
+ end
359
+
360
+ def database_id_container
361
+ Terrestrial::DatabaseID.new
362
+ end
363
+ end
364
+
365
+ # TODO: It is very tempting to implement database generated IDs in terms of this
366
+ class ArbitraryDatabaseOwnedValueObserver
367
+ def initialize(field_name, setter)
368
+ @field_name = field_name
369
+ @setter = setter
370
+ end
371
+
372
+ attr_reader :field_name, :setter
373
+ private :field_name, :setter
374
+
375
+ def post_serialize(*_args)
376
+ end
377
+
378
+ def post_save(mapping, object, record, new_record)
379
+ setter.call(object, new_record.get(field_name))
380
+ end
381
+ end
382
+
383
+ class ArbitraryDatabaseDefaultValueObserver
384
+ def initialize(field_name, setter)
385
+ @field_name = field_name
386
+ @setter = setter
387
+ end
388
+
389
+ attr_reader :field_name, :setter
390
+ private :field_name, :setter
391
+
392
+ def post_serialize(*_args)
393
+ end
394
+
395
+ def post_save(mapping, object, record, new_record)
396
+ if value_changed?(new_record, record)
397
+ setter.call(object, new_record.get(field_name))
398
+ end
399
+ end
400
+
401
+ private
402
+
403
+ def value_changed?(new_record, old_record)
404
+ new_record.attributes[field_name] != old_record.attributes[field_name]
405
+ end
406
+ end
407
+
408
+ class TimestampObserver
409
+ def initialize(clock, dirty_map, created_at_field, created_at_setter, updated_at_field, updated_at_setter)
410
+ @clock = clock
411
+ @dirty_map = dirty_map
412
+ @created_at_field = created_at_field
413
+ @created_at_setter = created_at_setter
414
+ @updated_at_field = updated_at_field
415
+ @updated_at_setter = updated_at_setter
416
+ end
417
+
418
+ attr_reader :clock, :dirty_map, :created_at_field, :updated_at_field, :created_at_setter, :updated_at_setter
419
+ private :clock, :dirty_map, :created_at_field, :updated_at_field, :created_at_setter, :updated_at_setter
420
+
421
+ def post_serialize(mapping, object, record)
422
+ time = clock.now
423
+
424
+ if created_at_field && !record.get(created_at_field)
425
+ record.set(created_at_field, time)
426
+ end
427
+
428
+ if updated_at_field && dirty_map.dirty?(record)
429
+ record.set(updated_at_field, time)
430
+ end
431
+ end
432
+
433
+ def post_save(mapping, object, record, new_record)
434
+ if created_at_field && record.fetch(created_at_field, false)
435
+ time = record.fetch(created_at_field)
436
+ created_at_setter.call(object, time)
437
+ end
438
+
439
+ if updated_at_field
440
+ time = record.get(updated_at_field)
441
+ updated_at_setter.call(object, time)
442
+ end
443
+ end
444
+ end
445
+
446
+ class SetterMethodCaller
447
+ def initialize(field_name)
448
+ raise "hell no" unless field_name
449
+ @setter_method = "#{field_name}="
450
+ end
451
+
452
+ def call(object, value)
453
+ object.public_send(@setter_method, value)
454
+ end
455
+ end
300
456
  end
301
457
  end
302
458
  end