flex_columns 1.0.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 (50) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +18 -0
  3. data/.travis.yml +38 -0
  4. data/Gemfile +17 -0
  5. data/LICENSE.txt +22 -0
  6. data/README.md +124 -0
  7. data/Rakefile +6 -0
  8. data/flex_columns.gemspec +72 -0
  9. data/lib/flex_columns.rb +15 -0
  10. data/lib/flex_columns/active_record/base.rb +57 -0
  11. data/lib/flex_columns/contents/column_data.rb +376 -0
  12. data/lib/flex_columns/contents/flex_column_contents_base.rb +188 -0
  13. data/lib/flex_columns/definition/field_definition.rb +316 -0
  14. data/lib/flex_columns/definition/field_set.rb +89 -0
  15. data/lib/flex_columns/definition/flex_column_contents_class.rb +327 -0
  16. data/lib/flex_columns/errors.rb +236 -0
  17. data/lib/flex_columns/has_flex_columns.rb +187 -0
  18. data/lib/flex_columns/including/include_flex_columns.rb +179 -0
  19. data/lib/flex_columns/util/dynamic_methods_module.rb +86 -0
  20. data/lib/flex_columns/util/string_utils.rb +31 -0
  21. data/lib/flex_columns/version.rb +4 -0
  22. data/spec/flex_columns/helpers/database_helper.rb +174 -0
  23. data/spec/flex_columns/helpers/exception_helpers.rb +20 -0
  24. data/spec/flex_columns/helpers/system_helpers.rb +47 -0
  25. data/spec/flex_columns/system/basic_system_spec.rb +245 -0
  26. data/spec/flex_columns/system/bulk_system_spec.rb +153 -0
  27. data/spec/flex_columns/system/compression_system_spec.rb +218 -0
  28. data/spec/flex_columns/system/custom_methods_system_spec.rb +120 -0
  29. data/spec/flex_columns/system/delegation_system_spec.rb +175 -0
  30. data/spec/flex_columns/system/dynamism_system_spec.rb +158 -0
  31. data/spec/flex_columns/system/error_handling_system_spec.rb +117 -0
  32. data/spec/flex_columns/system/including_system_spec.rb +285 -0
  33. data/spec/flex_columns/system/json_alias_system_spec.rb +171 -0
  34. data/spec/flex_columns/system/performance_system_spec.rb +218 -0
  35. data/spec/flex_columns/system/postgres_json_column_type_system_spec.rb +85 -0
  36. data/spec/flex_columns/system/types_system_spec.rb +93 -0
  37. data/spec/flex_columns/system/unknown_fields_system_spec.rb +126 -0
  38. data/spec/flex_columns/system/validations_system_spec.rb +111 -0
  39. data/spec/flex_columns/unit/active_record/base_spec.rb +32 -0
  40. data/spec/flex_columns/unit/contents/column_data_spec.rb +520 -0
  41. data/spec/flex_columns/unit/contents/flex_column_contents_base_spec.rb +253 -0
  42. data/spec/flex_columns/unit/definition/field_definition_spec.rb +617 -0
  43. data/spec/flex_columns/unit/definition/field_set_spec.rb +142 -0
  44. data/spec/flex_columns/unit/definition/flex_column_contents_class_spec.rb +733 -0
  45. data/spec/flex_columns/unit/errors_spec.rb +297 -0
  46. data/spec/flex_columns/unit/has_flex_columns_spec.rb +365 -0
  47. data/spec/flex_columns/unit/including/include_flex_columns_spec.rb +144 -0
  48. data/spec/flex_columns/unit/util/dynamic_methods_module_spec.rb +105 -0
  49. data/spec/flex_columns/unit/util/string_utils_spec.rb +23 -0
  50. metadata +286 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 40b84ad7e37fdca13654ced8a6682554e979c1f9
4
+ data.tar.gz: 9ccc746ceebd8ed7862eac56d2915450ecb0d5e6
5
+ SHA512:
6
+ metadata.gz: ca46c492b3243c5996da41f317b3395863744834cac8747a29ae51b1dd0e513e04631da9914fbbfd14d6f0a50c782eda57972cb52e6f6ace43df096f976ce94c
7
+ data.tar.gz: c9c326762cdfc3711654dd072a20d7f290e52e02c4e7bfbff4a9bc1f044060d7b22074ee816ec612085318907623a1242c0117b0451cc5fa908c387f70790ca5
data/.gitignore ADDED
@@ -0,0 +1,18 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
18
+ /spec_database_config.rb
data/.travis.yml ADDED
@@ -0,0 +1,38 @@
1
+ rvm:
2
+ - "1.8.7"
3
+ - "1.9.3"
4
+ - "2.0.0"
5
+ - "jruby-1.7.6"
6
+ env:
7
+ - FLEX_COLUMNS_AR_TEST_VERSION=3.0.20 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=mysql
8
+ - FLEX_COLUMNS_AR_TEST_VERSION=3.0.20 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=postgres
9
+ - FLEX_COLUMNS_AR_TEST_VERSION=3.0.20 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=sqlite
10
+ - FLEX_COLUMNS_AR_TEST_VERSION=3.1.12 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=mysql
11
+ - FLEX_COLUMNS_AR_TEST_VERSION=3.1.12 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=postgres
12
+ - FLEX_COLUMNS_AR_TEST_VERSION=3.1.12 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=sqlite
13
+ - FLEX_COLUMNS_AR_TEST_VERSION=3.2.16 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=mysql
14
+ - FLEX_COLUMNS_AR_TEST_VERSION=3.2.16 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=postgres
15
+ - FLEX_COLUMNS_AR_TEST_VERSION=3.2.16 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=sqlite
16
+ - FLEX_COLUMNS_AR_TEST_VERSION=4.0.2 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=mysql
17
+ - FLEX_COLUMNS_AR_TEST_VERSION=4.0.2 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=postgres
18
+ - FLEX_COLUMNS_AR_TEST_VERSION=4.0.2 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=sqlite
19
+ before_script:
20
+ - mysql -e 'create database myapp_test;'
21
+ - psql -c 'create database myapp_test;' -U postgres
22
+ matrix:
23
+ exclude:
24
+ # ActiveRecord 4.x doesn't support Ruby 1.8.7
25
+ - rvm: 1.8.7
26
+ env: FLEX_COLUMNS_AR_TEST_VERSION=4.0.2 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=mysql
27
+ - rvm: 1.8.7
28
+ env: FLEX_COLUMNS_AR_TEST_VERSION=4.0.2 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=postgres
29
+ - rvm: 1.8.7
30
+ env: FLEX_COLUMNS_AR_TEST_VERSION=4.0.2 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=sqlite
31
+ # There's a bug in ActiveRecord 3.1.x that makes it incompatible with Ruby 2.0
32
+ - rvm: 2.0.0
33
+ env: FLEX_COLUMNS_AR_TEST_VERSION=3.1.12 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=mysql
34
+ - rvm: 2.0.0
35
+ env: FLEX_COLUMNS_AR_TEST_VERSION=3.1.12 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=postgres
36
+ - rvm: 2.0.0
37
+ env: FLEX_COLUMNS_AR_TEST_VERSION=3.1.12 FLEX_COLUMNS_TRAVIS_CI_DATABASE_TYPE=sqlite
38
+
data/Gemfile ADDED
@@ -0,0 +1,17 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in flex_columns.gemspec
4
+ gemspec
5
+
6
+ ar_version = ENV['FLEX_COLUMNS_AR_TEST_VERSION']
7
+ ar_version = ar_version.strip if ar_version
8
+
9
+ version_spec = case ar_version
10
+ when nil then nil
11
+ when 'master' then { :git => 'git://github.com/rails/activerecord.git' }
12
+ else "=#{ar_version}"
13
+ end
14
+
15
+ if version_spec
16
+ gem("activerecord", version_spec)
17
+ end
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 Andrew Geweke
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,124 @@
1
+ # flex_columns
2
+
3
+ Schema-free, structured storage inside a RDBMS. Use a `VARCHAR`, `TEXT`, `CLOB`, `BLOB`, or `BINARY` column in your
4
+ schema to store structured data in JSON, while still letting you run validations against that data, build methods on
5
+ top of it, and automatically delegate it to your models. Far more powerful than ActiveRecord's built-in serialization
6
+ mechanism, `flex_columns` gives you the freedom of schemaless databases inside a proven RDBMS.
7
+
8
+ Combined with [`low_card_tables`](https://github.com/ageweke/low_card_tables), allows a RDBMS to represent a wide
9
+ variety of data efficiently and with a great deal of flexibility — build your projects rapidly and effectively
10
+ while relying on the most reliable, manageable, proven data engines out there.
11
+
12
+ Supported platforms:
13
+
14
+ * Ruby 1.8.7, 1.9.3, 2.0.0, and JRuby 1.7.6.
15
+ * ActiveRecord 3.0.20, 3.1.12, 3.2.16, and 4.0.2. (Should be compatible with future versions, as well...just sits on top of the public API of ActiveRecord.)
16
+ * Tested against MySQL, PostgreSQL, and SQLite 3. (Should be compatible with all RDBMSes supported by ActiveRecord.)
17
+
18
+ Current build status: ![Current Build Status](https://api.travis-ci.org/ageweke/flex_columns.png?branch=master)
19
+
20
+ ### Installing flex_columns
21
+
22
+ # Gemfile
23
+ gem 'flex_columns'
24
+
25
+ ### Example
26
+
27
+ As an example — assume table `users` has a `CLOB` column `user_attributes`:
28
+
29
+ class User < ActiveRecord::Base
30
+ ...
31
+ flex_column :user_attributes do
32
+ field :locale
33
+ field :comments_display_mode
34
+ field :custom_page_color
35
+ field :nickname
36
+ end
37
+ ...
38
+ end
39
+
40
+ You can now write code like:
41
+
42
+ user = User.find(...)
43
+ user.locale = :fr_FR
44
+
45
+ case user.comments_display_mode
46
+ when 'threaded' then ...
47
+ when 'linear' then ...
48
+ end
49
+
50
+ ### Robust Example
51
+
52
+ As a snapshot of all possibilities:
53
+
54
+ # Assume we're storing the JSON in a wholly separate table, so we don't have to load it unless we need it...
55
+ class UserDetails < ActiveRecord::Base
56
+ flex_column :user_attributes,
57
+ :compress => 100, # try compressing any JSON >= 100 bytes, but only store compressed if it's smaller
58
+ :visibility => :private, # attributes are private by default
59
+ :prefix => :ua, # sets a prefix for methods delegated from the outer class
60
+ :unknown_fields => :delete # if DB contains fields not declared here, delete those keys when saving
61
+ do
62
+ # automatically adds validations requiring a string that's non-nil
63
+ field :locale, :string, :null => false
64
+ # automatically adds validations requiring the value to be one of the listed values
65
+ field :comments_display_mode, :enum => %w{threaded linear collapsed}
66
+ # in the JSON in the database, the key will be 'cpc', not 'custom_page_color', to save space
67
+ field :custom_page_color, :json => :cpc
68
+ field :nickname
69
+ field :visit_count, :integer
70
+
71
+ # Use the full gamut of Rails validations -- they will run automatically when saving a User
72
+ validates :custom_page_color, :format => { :with => /^\#[0-9a-f]{6}/i, :message => 'must be a valid HTML hex color' }
73
+
74
+ # Define custom methods...
75
+ def french?
76
+ [ :fr_FR, :fr_CA ].include?(locale)
77
+ end
78
+
79
+ # +super+ works correctly in all cases
80
+ def visit_count
81
+ super || 0
82
+ end
83
+
84
+ # You can also access attributes using Hash syntax
85
+ def increment_visit_count!
86
+ self[:visit_count] += 1
87
+ end
88
+ end
89
+ end
90
+
91
+ # And now transparently include it into our User class...
92
+ class User < ActiveRecord::Base
93
+ has_one :user_details
94
+
95
+ include_flex_columns_from :user_details
96
+ end
97
+
98
+ ...and then you can write code like so:
99
+
100
+ user = User.find(...)
101
+
102
+ user.user_attributes.french? # access directly from the column
103
+ user.ua_visit_count # :prefix prefixed the delegated method names with the desired string
104
+
105
+ user.visit_count = 'foo' # sets an invalid value
106
+ user.save # => false; user isn't valid
107
+ user.errors.keys # => :'user_attributes.visit_count'
108
+ user.errors[:'user_attributes.visit_count'] # => [ 'must be a number' ]
109
+
110
+ There's lots more, too:
111
+
112
+ * Complete validations support: the flex-column object includes ActiveModel::Validations, so every single Rails validation (or custom validations) will work perfectly
113
+ * Bulk operations, for avoiding ActiveRecord instantiation (efficiently operate using raw +select_all+ and +activerecord-import+ or similar systems)
114
+ * Transparently compresses JSON data in the column using GZip, if it's typed as binary (`BINARY`, `VARBINARY`, `CLOB`, etc.); you can fully control this, or turn it off if you want
115
+ * Happily allows definition and redefinition of flex columns at any time, for full dynamism and compatibility with development mode of Rails
116
+ * Rich error hierarchy and detailed exception messages &mdash; you will know exactly what went wrong when something goes wrong
117
+ * Include flex columns across associations, with control over exactly what's delegated and visibility of those methods (public or private)
118
+ * Control whether attribute methods generated are public (default) or private (to encourage encapsulation)
119
+ * "Types": automatically adds validations that require fields to comply with database types like +:integer+, +:string+, +:timestamp+, etc.
120
+ * Decide whether to preserve (the default) or delete keys from the underlying JSON that aren't defined in the flex column &mdash; lets you ensure database data is of the highest quality, or be compatible with any other storage mechanisms
121
+
122
+ ### Documentation
123
+
124
+ # Documentation is on [the Wiki](https://github.com/ageweke/flex_columns/wiki)!
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,72 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'flex_columns/version'
5
+
6
+ Gem::Specification.new do |s|
7
+ s.name = "flex_columns"
8
+ s.version = FlexColumns::VERSION
9
+ s.authors = ["Andrew Geweke"]
10
+ s.email = ["andrew@geweke.org"]
11
+ s.homepage = "https://github.com/ageweke/flex_columns"
12
+ s.description = %q{Schema-free, structured JSON storage inside a RDBMS.}
13
+ s.summary = %q{Schema-free, structured JSON storage inside a RDBMS.}
14
+ s.license = "MIT"
15
+
16
+ s.files = `git ls-files`.split($/)
17
+ s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ s.test_files = s.files.grep(%r{^(test|spec|features)/})
19
+ s.require_paths = ["lib"]
20
+
21
+ s.add_dependency 'json'
22
+
23
+ s.add_development_dependency "bundler", "~> 1.3"
24
+ s.add_development_dependency "rake"
25
+ s.add_development_dependency "rspec", "~> 2.14"
26
+
27
+ if (RUBY_VERSION =~ /^1\.9\./ || RUBY_VERSION =~ /^2\.0\./) && ((! defined?(RUBY_ENGINE)) || (RUBY_ENGINE != 'jruby'))
28
+ s.add_development_dependency "pry"
29
+ s.add_development_dependency "pry-debugger"
30
+ s.add_development_dependency "pry-stack_explorer"
31
+ end
32
+
33
+ ar_version = ENV['FLEX_COLUMNS_AR_TEST_VERSION']
34
+ ar_version = ar_version.strip if ar_version
35
+
36
+ version_spec = case ar_version
37
+ when nil then [ ">= 3.0", "<= 4.99.99" ]
38
+ when 'master' then nil
39
+ else [ "=#{ar_version}" ]
40
+ end
41
+
42
+ if version_spec
43
+ s.add_dependency("activerecord", *version_spec)
44
+ end
45
+
46
+ s.add_dependency "activesupport", ">= 3.0", "<= 4.99.99"
47
+
48
+ ar_import_version = case ar_version
49
+ when nil then nil
50
+ when 'master', /^4\.0\./ then '~> 0.4.1'
51
+ when /^3\.0\./ then '~> 0.2.11'
52
+ when /^3\.1\./, /^3\.2\./ then '~> 0.3.1'
53
+ else raise "Don't know what activerecord-import version to require for activerecord version #{ar_version.inspect}!"
54
+ end
55
+
56
+ if ar_import_version
57
+ s.add_dependency("activerecord-import", ar_import_version)
58
+ else
59
+ s.add_dependency("activerecord-import")
60
+ end
61
+
62
+ require File.expand_path(File.join(File.dirname(__FILE__), 'spec', 'flex_columns', 'helpers', 'database_helper'))
63
+ database_gem_name = FlexColumns::Helpers::DatabaseHelper.maybe_database_gem_name
64
+
65
+ # Ugh. Later versions of the 'mysql2' gem are incompatible with AR 3.0.x; so, here, we explicitly trap that case
66
+ # and use an earlier version of that Gem.
67
+ if database_gem_name && database_gem_name == 'mysql2' && ar_version && ar_version =~ /^3\.0\./
68
+ s.add_development_dependency('mysql2', '~> 0.2.0')
69
+ else
70
+ s.add_development_dependency(database_gem_name)
71
+ end
72
+ end
@@ -0,0 +1,15 @@
1
+ require 'active_record'
2
+ require "flex_columns/version"
3
+ require "flex_columns/active_record/base"
4
+
5
+ # The FlexColumns module. Currently, we use this for nothing more than a namespace for our various classes.
6
+ module FlexColumns
7
+ end
8
+
9
+ # Include a very few methods into ActiveRecord::Base. If you declare a flex column using +flex_column+, or include
10
+ # flex columns using +include_flex_columns_from+, we include additional modules into your model class that do more
11
+ # work. This strategy lets us make sure we add as little as possible to ActiveRecord::Base for classes that don't
12
+ # have anything to do with flex columns.
13
+ class ActiveRecord::Base
14
+ include FlexColumns::ActiveRecord::Base
15
+ end
@@ -0,0 +1,57 @@
1
+ require 'active_record'
2
+ require 'active_support/concern'
3
+ require 'flex_columns/has_flex_columns'
4
+ require 'flex_columns/including/include_flex_columns'
5
+
6
+ module FlexColumns
7
+ module ActiveRecord
8
+ # This is the module that gets included into ::ActiveRecord::Base when +flex_columns+ is loaded. (No other changes
9
+ # are made to the ActiveRecord API, except for classes where you've declared +flex_column+ or
10
+ # +include_flex_columns_from+.) All it does is look for calls to our methods, and, when they are called, +include+
11
+ # the correct module and then repeat the call again.
12
+ module Base
13
+ extend ActiveSupport::Concern
14
+
15
+ module ClassMethods
16
+ # Does this class have any flex columns? FlexColumns::HasFlexColumns overrides this to return +true+.
17
+ def has_any_flex_columns?
18
+ false
19
+ end
20
+
21
+ # Declares a flex column. Includes FlexColumns::HasFlexColumns, and then does what looks like an
22
+ # infinitely-recursing call -- but, because Ruby is so badass, this actually calls the method that has just
23
+ # been included into the class, instead (i.e., the one from FlexColumns::HasFlexColumns).
24
+ #
25
+ # See FlexColumns::HasFlexColumns#flex_column for more information.
26
+ def flex_column(*args, &block)
27
+ include FlexColumns::HasFlexColumns
28
+ flex_column(*args, &block)
29
+ end
30
+
31
+ # Includes flex columns from another class. Includes FlexColumns::Including::IncludeFlexColumns, and then does
32
+ # what looks like an infinitely-recursing call -- but, because Ruby is so badass, this actually calls the method
33
+ # that has just been included into the class, instead (i.e., the one from
34
+ # FlexColumns::Including::IncludeFlexColumns).
35
+ #
36
+ # See FlexColumns::Including::IncludeFlexColumns#include_flex_columns_from for more information.
37
+ def include_flex_columns_from(*args, &block)
38
+ include FlexColumns::Including::IncludeFlexColumns
39
+ include_flex_columns_from(*args, &block)
40
+ end
41
+
42
+ def _flex_columns_safe_to_define_method?(method_name)
43
+ base_name = method_name.to_s
44
+ base_name = $1 if base_name =~ /^(.*)=$/i
45
+
46
+ reason = nil
47
+
48
+ reason ||= :column if columns.detect { |c| c.name.to_s == base_name }
49
+ # return false if method_defined?(base_name) || method_defined?("#{base_name}=")
50
+ reason ||= :instance_method if instance_methods(false).map(&:to_s).include?(base_name.to_s)
51
+
52
+ (! reason)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,376 @@
1
+ require 'flex_columns/errors'
2
+ require 'stringio'
3
+ require 'zlib'
4
+
5
+ module FlexColumns
6
+ module Contents
7
+ # ColumnData is one of the core classes in +flex_columns+. An instance of ColumnData represents the data present
8
+ # in a single row for a single flex column; it stores that data, is used to set and retrieve that data, and can
9
+ # serialize and deserialize itself from and to JSON (with headers and optional compression added for binary storage).
10
+ #
11
+ # Clients do not interact with ColumnData itself; rather, they interact with an instance of a generated subclass
12
+ # of FlexColumnsContentsBase, and it delegates core methods to this object.
13
+ class ColumnData
14
+ # Creates a new instance. +field_set+ is the FlexColumns::Definition::FieldSet that contains the set of fields
15
+ # defined for this flex column; +options+ can contain:
16
+ #
17
+ # [:storage_string] The data present in the column in the database; this can be omitted if creating an instance
18
+ # for a row that has no data, or for a new row.
19
+ # [:data_source] Where did that data come from? This can be any object; it must respond to
20
+ # #describe_flex_column_data_source (no arguments), which should return a String that is used
21
+ # in thrown exceptions to let the client know what data caused the problem; it also must respond to
22
+ # #notification_hash_for_flex_column_data_source (no arguments), which should return a Hash that
23
+ # is used to generate the payload for the ActiveSupport::Notification calls this class makes.
24
+ # (This is, in practice, always an instance of the FlexColumnsContentsBase subclass generated for the
25
+ # column.)
26
+ # [:unknown_fields] Must pass +:preserve+ or +:delete+. If there are keys in the serialized JSON that do not
27
+ # correspond to any fields that the FieldSet knows about, this determines what will happen to
28
+ # that data when re-serializing it to save: +:preserve+ keeps that data, while +:delete+ removes
29
+ # it. (In neither case is that data actually accessible; you must declare a field if you want
30
+ # access to it.)
31
+ # [:length_limit] If present, specifies the maximum length of data that can be stored in the underlying storage
32
+ # mechanism (the column). When serializing data, this object will raise an exception if the
33
+ # serialized form is longer than this limit. This is used to avoid cases where the database might
34
+ # otherwise silently truncate the data being stored (I'm looking at you, MySQL) and hence corrupt
35
+ # stored data.
36
+ # [:storage] This must be +:binary+, +:text+, or :json. If +:text+, standard, uncompressed JSON will always be stored.
37
+ # (It is not possible to store compressed data reliably in a text column, because the database will
38
+ # interpret the bytes as characters and may modify them or raise an exception if byte sequences are
39
+ # present that would be invalid characters in whatever encoding it's using.) If :binary, then a very
40
+ # small header will be written that's just for versioning (currently +FC:01,+), followed by a marker
41
+ # indicating if it's compressed (+1,+) or not (+0,+), followed by either standard, uncompressed JSON
42
+ # encoded in UTF-8 or the GZipped version of the same. If :json, then we assume the database has
43
+ # a native JSON type (like PostgreSQL with sufficiently-recent ActiveRecord and PG gem), and deal in
44
+ # an actual Hash, which the database processes directly.
45
+ # [:compress_if_over_length] If present, must be set to an integer. If +:storage+ is +:binary+ and the JSON string
46
+ # is at least this many bytes long, then this class will compress it before
47
+ # returning its stored data (from #to_stored_data); if the compressed version is at
48
+ # most 95% (MIN_SIZE_REDUCTION_RATIO_FOR_COMPRESSION) as long as the uncompressed
49
+ # version, then the compressed version will be used instead.
50
+ # [:binary_header] Must be +true+ or +false+. If +false+, then, even if +:storage+ is +:binary+, no header will be
51
+ # written to the binary column. (As a consequence, compression will also be disabled, since
52
+ # compression requires the header.)
53
+ # [:null] Must be +true+ or +false+. If +false+, assumes the underlying column in the database is defined as
54
+ # non-NULL (although this is not recommended), and therefore will set an empty string ("") on the column
55
+ # if there's no data in it, rather than SQL +NULL+.
56
+ def initialize(field_set, options = { })
57
+ options.assert_valid_keys(:storage_string, :data_source, :unknown_fields, :length_limit, :storage,
58
+ :compress_if_over_length, :binary_header, :null)
59
+
60
+ @storage_string = options[:storage_string]
61
+ @field_set = field_set
62
+ @data_source = options[:data_source]
63
+ @unknown_fields = options[:unknown_fields]
64
+ @length_limit = options[:length_limit]
65
+ @storage = options[:storage]
66
+ @compress_if_over_length = options[:compress_if_over_length]
67
+ @binary_header = options[:binary_header]
68
+ @null = options[:null]
69
+
70
+ raise ArgumentError, "Invalid JSON string: #{storage_string.inspect}" if storage_string && (! storage_string.kind_of?(String)) && (! storage_string.kind_of?(Hash))
71
+ raise ArgumentError, "Must supply a FieldSet, not: #{field_set.inspect}" unless field_set.kind_of?(FlexColumns::Definition::FieldSet)
72
+ raise ArgumentError, "Must supply a data source, not: #{data_source.inspect}" unless data_source
73
+ raise ArgumentError, "Invalid value for :unknown_fields: #{unknown_fields.inspect}" unless [ :preserve, :delete ].include?(unknown_fields)
74
+ raise ArgumentError, "Invalid value for :length_limit: #{length_limit.inspect}" if length_limit && (! (length_limit.kind_of?(Integer) && length_limit >= 8))
75
+ raise ArgumentError, "Invalid value for :storage: #{storage.inspect}" unless [ :binary, :text, :json ].include?(storage)
76
+ raise ArgumentError, "Invalid value for :compress_if_over_length: #{compress_if_over_length.inspect}" if compress_if_over_length && (! compress_if_over_length.kind_of?(Integer))
77
+ raise ArgumentError, "Invalid value for :binary_header: #{binary_header.inspect}" unless [ true, false ].include?(binary_header)
78
+ raise ArgumentError, "Invalid value for :null: #{null.inspect}" unless [ true, false ].include?(null)
79
+
80
+
81
+ @field_contents_by_field_name = nil
82
+ @unknown_field_contents_by_key = nil
83
+ @touched = false
84
+ end
85
+
86
+ # Returns the data for the given +field_name+. Raises FlexColumns::Errors::NoSuchFieldError if there is no field
87
+ # of the given name. Returns nil if there is such a field, but no data for it.
88
+ def [](field_name)
89
+ field_name = validate_and_deserialize_for_field(field_name)
90
+ field_contents_by_field_name[field_name]
91
+ end
92
+
93
+ # Sets the data for the given +field_name+ to the given +new_value+. Raises FlexColumns::Errors::NoSuchFieldError
94
+ # if there is no field of the given name. Returns +new_value+.
95
+ def []=(field_name, new_value)
96
+ field_name = validate_and_deserialize_for_field(field_name)
97
+
98
+ # We do this for a very good reason. When encoding as JSON, Ruby's JSON library happily accepts Symbols, but
99
+ # encodes them as simple Strings in the JSON. (This makes sense, because JSON doesn't support Symbols.) This
100
+ # means that if you save a value in a flex column as a Symbol, and then re-read that row from the database,
101
+ # you'll get back a String, not the Symbol you put in.
102
+ #
103
+ # Unfortunately, this is different from what you'll get if there is no intervening save/load cycle, where it'd
104
+ # otherwise stay a Symbol. This difference in behavior can be the source of some really annoying bugs. While
105
+ # ActiveRecord has this annoying behavior, this is a chance to clean it up in a small way -- so, if you set a
106
+ # Symbol, we return a String. (And, yes, this has no bearing on Symbols stored nested inside Arrays or Hashes;
107
+ # and that's OK.)
108
+ new_value = new_value.to_s if new_value.kind_of?(Symbol)
109
+
110
+ old_value = field_contents_by_field_name[field_name]
111
+
112
+ @touched = true if old_value != new_value
113
+
114
+ # We deliberately delete from the hash anything that's being set to +nil+; this is so that we don't end up just
115
+ # binding keys to +nil+, and returning them in #keys, etc. (Yes, this means that you can't distinguish a key
116
+ # explicitly set to +nil+ from a key that's not present; this is different from Ruby's semantics for a Hash,
117
+ # but not by very much, and it makes use of +flex_columns+ a whole lot simpler.)
118
+ if new_value == nil
119
+ field_contents_by_field_name.delete(field_name)
120
+ nil
121
+ else
122
+ field_contents_by_field_name[field_name] = new_value
123
+ end
124
+ end
125
+
126
+ # Returns an Array of all field names that are currently set to something.
127
+ def keys
128
+ deserialize_if_necessary!
129
+ field_contents_by_field_name.keys
130
+ end
131
+
132
+ # Does nothing, other than making sure the JSON has been deserialized. This therefore has the effect both of
133
+ # ensuring that the stored data (if any) is valid, and also will remove any unknown keys (on save) if
134
+ # +:unknown_fields+ was set to +:delete+.
135
+ def touch!
136
+ deserialize_if_necessary!
137
+ @touched = true
138
+ end
139
+
140
+ # Has this object been modified in any way?
141
+ def touched?
142
+ !! @touched
143
+ end
144
+
145
+ # Returns a String with the current contents of this object as JSON. (This will deserialize from JSON, if it
146
+ # hasn't already happened.)
147
+ #
148
+ # Always returns a string encoded in UTF-8, if we're running on a Ruby >= 1.9 (that is, with encoding support).
149
+ def to_json
150
+ deserialize_if_necessary!
151
+
152
+ json_hash = to_json_hash
153
+ as_string = json_hash.to_json
154
+ as_string = as_string.encode(Encoding::UTF_8) if as_string.respond_to?(:encode)
155
+
156
+ as_string
157
+ end
158
+
159
+ # Returns the exact String that should be stored in the database -- compressed or not, with header or not, etc.
160
+ # Raises FlexColumns::Errors::JsonTooLongError if the string is too long to fit in the database.
161
+ #
162
+ # (Under PostgreSQL, with appropriate ActiveRecord and PostgreSQL support,)
163
+ def to_stored_data
164
+ out = nil
165
+
166
+ deserialize_if_necessary!
167
+
168
+ return to_json_hash if storage == :json
169
+
170
+ instrument("serialize") do
171
+ if storage == :json
172
+ out = to_json_hash
173
+ else
174
+ out = to_json
175
+
176
+ if out.length < 8 && out =~ /^\s*\{\s*\}\s*$/i
177
+ out = @null ? nil : ""
178
+ else
179
+ out = to_binary_storage(out) if storage == :binary
180
+ end
181
+ end
182
+ end
183
+
184
+ if length_limit && out.length > length_limit
185
+ raise FlexColumns::Errors::JsonTooLongError.new(data_source, length_limit, out)
186
+ end
187
+
188
+ out
189
+ end
190
+
191
+ private
192
+ attr_reader :storage_string, :field_set, :data_source, :unknown_fields, :length_limit, :storage, :compress_if_over_length
193
+ attr_reader :field_contents_by_field_name, :unknown_field_contents_by_key, :binary_header, :null
194
+
195
+ # What's the current version number of our storage format? Because we only have a single version right now,
196
+ # this is also the only version we accept.
197
+ FLEX_COLUMN_CURRENT_VERSION_NUMBER = 1
198
+
199
+ # What maximum fraction of the uncompressed size does a compressed string have to be before we use it in preference
200
+ # to the uncompressed string?
201
+ MIN_SIZE_REDUCTION_RATIO_FOR_COMPRESSION = 0.95
202
+
203
+ # Returns a Hash with exactly the key-to-value mappings that we'd store as JSON -- that is, uses fields'
204
+ # JSON storage aliases, not field names, and omits unknown fields if <tt>unknown_fields == :delete</tt>.
205
+ def to_json_hash
206
+ json_hash = { }
207
+ json_hash.merge!(unknown_field_contents_by_key) unless unknown_fields == :delete
208
+
209
+ field_contents_by_field_name.each do |field_name, field_contents|
210
+ storage_name = field_set.field_named(field_name).json_storage_name
211
+ json_hash[storage_name] = field_contents
212
+ end
213
+
214
+ json_hash
215
+ end
216
+
217
+ # Fires the appropriate +flex_columns+ notification with the given +name+, any +additional+ options in the payload,
218
+ # wrapped around the supplied block.
219
+ def instrument(name, additional = { }, &block)
220
+ ::ActiveSupport::Notifications.instrument("flex_columns.#{name}", data_source.notification_hash_for_flex_column_data_source.merge(additional), &block)
221
+ end
222
+
223
+ # Given a +field_name+, ensures that that is, in fact, a valid field name, and that we have been deserialized.
224
+ # Used for implementing #[] and #[]=.
225
+ def validate_and_deserialize_for_field(field_name)
226
+ field = field_set.field_named(field_name)
227
+ unless field
228
+ raise FlexColumns::Errors::NoSuchFieldError.new(data_source, field_name, field_set.all_field_names)
229
+ end
230
+
231
+ deserialize_if_necessary!
232
+
233
+ field.field_name
234
+ end
235
+
236
+ # Given a JSON string, returns the appropriate binary-storage string. This is the method that figures out
237
+ # whether we should compress the data or not and applies the binary header, if appropriate.
238
+ def to_binary_storage(json_string)
239
+ json_string = json_string.force_encoding(Encoding::BINARY) if json_string.respond_to?(:force_encoding)
240
+ return json_string if (! binary_header)
241
+
242
+ header = "FC:%02d," % FLEX_COLUMN_CURRENT_VERSION_NUMBER
243
+
244
+ json_length = if json_string.respond_to?(:bytesize) then json_string.bytesize else json_string.length end
245
+
246
+ if compress_if_over_length && json_length > compress_if_over_length
247
+ compressed = compress(json_string)
248
+ compressed.force_encoding(Encoding::BINARY) if compressed.respond_to?(:force_encoding)
249
+ compressed = header + "1," + compressed
250
+ compressed.force_encoding(Encoding::BINARY) if compressed.respond_to?(:force_encoding)
251
+ end
252
+
253
+ compressed_length = if compressed
254
+ if compressed.respond_to?(:bytesize)
255
+ compressed.bytesize
256
+ else
257
+ compressed.length
258
+ end
259
+ end
260
+
261
+ if compressed_length && compressed_length < (MIN_SIZE_REDUCTION_RATIO_FOR_COMPRESSION * json_length)
262
+ compressed
263
+ else
264
+ header + "0," + json_string
265
+ end
266
+ end
267
+
268
+ # Compresses a string with GZip and returns its compressed representation.
269
+ def compress(json_string)
270
+ stream = StringIO.new("w")
271
+ writer = Zlib::GzipWriter.new(stream)
272
+ writer.write(json_string)
273
+ writer.close
274
+
275
+ stream.string
276
+ end
277
+
278
+ # Decompresses a GZipped string and returns the decompressed version.
279
+ def decompress(data, raw_data)
280
+ begin
281
+ input = StringIO.new(data, "r")
282
+ reader = Zlib::GzipReader.new(input)
283
+ reader.read
284
+ rescue Zlib::GzipFile::Error => gze
285
+ raise FlexColumns::Errors::InvalidCompressedDataInDatabaseError.new(data_source, raw_data, gze)
286
+ end
287
+ end
288
+
289
+ # Given a storage string, returns a pure-JSON string. This involves looking for a header, and, if it's present,
290
+ # validating it and uncompressing the content (if compressed).
291
+ def from_stored_data(storage_string)
292
+ if storage_string =~ /^(FC:(\d+),(\d+),)/i
293
+ prefix = $1
294
+ version_number = Integer($2)
295
+ compressed = Integer($3)
296
+ remaining_data = storage_string[prefix.length..-1]
297
+
298
+ if version_number > FLEX_COLUMN_CURRENT_VERSION_NUMBER
299
+ raise FlexColumns::Errors::InvalidFlexColumnsVersionNumberInDatabaseError.new(
300
+ data_source, storage_string, version_number, FLEX_COLUMN_CURRENT_VERSION_NUMBER)
301
+ end
302
+
303
+ case compressed
304
+ when 0 then remaining_data
305
+ when 1 then decompress(remaining_data, storage_string)
306
+ else raise FlexColumns::Errors::InvalidDataInDatabaseError.new(
307
+ data_source, storage_string, "the compression number was #{compressed.inspect}, not 0 or 1.")
308
+ end
309
+ else
310
+ storage_string
311
+ end
312
+ end
313
+
314
+ # Parses JSON. This just adds exception handling that tells you exactly where the failure was.
315
+ def parse_json(json)
316
+ out = begin
317
+ JSON.parse(json)
318
+ rescue ::JSON::ParserError => pe
319
+ raise FlexColumns::Errors::UnparseableJsonInDatabaseError.new(data_source, json, pe)
320
+ end
321
+
322
+ unless out.kind_of?(Hash)
323
+ raise FlexColumns::Errors::InvalidJsonInDatabaseError.new(data_source, json, out)
324
+ end
325
+
326
+ out
327
+ end
328
+
329
+ # Given a hash returned by parsing JSON, stores the data away in either @field_contents_by_field_name or
330
+ # @unknown_field_contents_by_key, depending on whether the data matches one of our fields or not.
331
+ def store_fields!(parsed_hash)
332
+ @field_contents_by_field_name = { }
333
+ @unknown_field_contents_by_key = { }
334
+
335
+ parsed_hash.each do |field_name, field_value|
336
+ field = field_set.field_with_json_storage_name(field_name)
337
+ if field
338
+ @field_contents_by_field_name[field.field_name] = field_value
339
+ else
340
+ @unknown_field_contents_by_key[field_name] = field_value
341
+ end
342
+ end
343
+ end
344
+
345
+ # If we haven't yet deserialized the JSON string, do it now, and store the data appropriately. This also
346
+ # checks for a validly-encoded string.
347
+ def deserialize_if_necessary!
348
+ unless field_contents_by_field_name
349
+ raw_data = storage_string || ''
350
+
351
+ # PostgreSQL's JSON data type, combined with recent-enough adapters and ActiveRecord, will return JSON as a
352
+ # Hash directly from the driver (!).
353
+ if raw_data.kind_of?(Hash)
354
+ store_fields!(raw_data)
355
+ return
356
+ end
357
+
358
+ if raw_data.respond_to?(:valid_encoding?) && (! raw_data.valid_encoding?)
359
+ raise FlexColumns::Errors::IncorrectlyEncodedStringInDatabaseError.new(data_source, raw_data)
360
+ end
361
+
362
+ if raw_data.strip.length > 0
363
+ parsed = instrument("deserialize", :raw_data => raw_data) do
364
+ parse_json(from_stored_data(raw_data))
365
+ end
366
+
367
+ store_fields!(parsed)
368
+ else
369
+ @field_contents_by_field_name = { }
370
+ @unknown_field_contents_by_key = { }
371
+ end
372
+ end
373
+ end
374
+ end
375
+ end
376
+ end