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.
- checksums.yaml +7 -0
- data/.gitignore +18 -0
- data/.travis.yml +38 -0
- data/Gemfile +17 -0
- data/LICENSE.txt +22 -0
- data/README.md +124 -0
- data/Rakefile +6 -0
- data/flex_columns.gemspec +72 -0
- data/lib/flex_columns.rb +15 -0
- data/lib/flex_columns/active_record/base.rb +57 -0
- data/lib/flex_columns/contents/column_data.rb +376 -0
- data/lib/flex_columns/contents/flex_column_contents_base.rb +188 -0
- data/lib/flex_columns/definition/field_definition.rb +316 -0
- data/lib/flex_columns/definition/field_set.rb +89 -0
- data/lib/flex_columns/definition/flex_column_contents_class.rb +327 -0
- data/lib/flex_columns/errors.rb +236 -0
- data/lib/flex_columns/has_flex_columns.rb +187 -0
- data/lib/flex_columns/including/include_flex_columns.rb +179 -0
- data/lib/flex_columns/util/dynamic_methods_module.rb +86 -0
- data/lib/flex_columns/util/string_utils.rb +31 -0
- data/lib/flex_columns/version.rb +4 -0
- data/spec/flex_columns/helpers/database_helper.rb +174 -0
- data/spec/flex_columns/helpers/exception_helpers.rb +20 -0
- data/spec/flex_columns/helpers/system_helpers.rb +47 -0
- data/spec/flex_columns/system/basic_system_spec.rb +245 -0
- data/spec/flex_columns/system/bulk_system_spec.rb +153 -0
- data/spec/flex_columns/system/compression_system_spec.rb +218 -0
- data/spec/flex_columns/system/custom_methods_system_spec.rb +120 -0
- data/spec/flex_columns/system/delegation_system_spec.rb +175 -0
- data/spec/flex_columns/system/dynamism_system_spec.rb +158 -0
- data/spec/flex_columns/system/error_handling_system_spec.rb +117 -0
- data/spec/flex_columns/system/including_system_spec.rb +285 -0
- data/spec/flex_columns/system/json_alias_system_spec.rb +171 -0
- data/spec/flex_columns/system/performance_system_spec.rb +218 -0
- data/spec/flex_columns/system/postgres_json_column_type_system_spec.rb +85 -0
- data/spec/flex_columns/system/types_system_spec.rb +93 -0
- data/spec/flex_columns/system/unknown_fields_system_spec.rb +126 -0
- data/spec/flex_columns/system/validations_system_spec.rb +111 -0
- data/spec/flex_columns/unit/active_record/base_spec.rb +32 -0
- data/spec/flex_columns/unit/contents/column_data_spec.rb +520 -0
- data/spec/flex_columns/unit/contents/flex_column_contents_base_spec.rb +253 -0
- data/spec/flex_columns/unit/definition/field_definition_spec.rb +617 -0
- data/spec/flex_columns/unit/definition/field_set_spec.rb +142 -0
- data/spec/flex_columns/unit/definition/flex_column_contents_class_spec.rb +733 -0
- data/spec/flex_columns/unit/errors_spec.rb +297 -0
- data/spec/flex_columns/unit/has_flex_columns_spec.rb +365 -0
- data/spec/flex_columns/unit/including/include_flex_columns_spec.rb +144 -0
- data/spec/flex_columns/unit/util/dynamic_methods_module_spec.rb +105 -0
- data/spec/flex_columns/unit/util/string_utils_spec.rb +23 -0
- 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
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: 
|
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 — 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 — 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,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
|
data/lib/flex_columns.rb
ADDED
@@ -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
|