squint 0.0.3 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 861884fea5cb7280a63e0759876ae2aae7a00750
4
- data.tar.gz: 6e2a4f8b024b165ef215e3a135063fdb235002f4
3
+ metadata.gz: df371ee0e87ea3faac9e9fc9a41b348f0ff30424
4
+ data.tar.gz: d44f014f0e8fa11d00f23bf19a6fa29d4954b035
5
5
  SHA512:
6
- metadata.gz: c8403702ac72aaef517fb97ef8042f9bb28170cddf4edbe6e11c655993f29301206d2bd7b70bf2c943fb5f6ef7f782141d044acd1f61ad539995575b7d4c238b
7
- data.tar.gz: 7f355faa0113834c322e9c13093ff556914bfff0438e809144eedfb0efe729b72cc6174f72fa348281c03ea65739ba5921720ca414029cbccc32bd502afb1fb3
6
+ metadata.gz: b8459d887335bb272cbd9d0fb8e31f012d2c573e710c3dc2b2a4d6e07057187171c7dbc8e9fbd873cec58741199aae5804861698c9eba2bdfaf5fd6ae6e3f69b
7
+ data.tar.gz: d000d6ebde399bb8edaead6c27e43aeb78575b81072eb9d0f0aa96e860cfb61bf45d0e169a1830bf6f7428e4c0511cd4798ac1ae900f882a636276d3a35a2345
@@ -0,0 +1,86 @@
1
+ {
2
+ "projectName": "squint",
3
+ "projectOwner": "ProctorU",
4
+ "files": [
5
+ "readme.md"
6
+ ],
7
+ "imageSize": 100,
8
+ "commit": true,
9
+ "contributors": [
10
+ {
11
+ "login": "chevinbrown",
12
+ "name": "Kevin Brown",
13
+ "avatar_url": "https://avatars2.githubusercontent.com/u/864581?v=3",
14
+ "profile": "https://github.com/chevinbrown",
15
+ "contributions": [
16
+ "design",
17
+ "review"
18
+ ]
19
+ },
20
+ {
21
+ "login": "king601",
22
+ "name": "Andrew Fomera",
23
+ "avatar_url": "https://avatars2.githubusercontent.com/u/1741179?v=3",
24
+ "profile": "http://andrewfomera.com",
25
+ "contributions": [
26
+ "review"
27
+ ]
28
+ },
29
+ {
30
+ "login": "Jaehdawg",
31
+ "name": "Matthew Jaeh",
32
+ "avatar_url": "https://avatars2.githubusercontent.com/u/1785682?v=3",
33
+ "profile": "https://github.com/Jaehdawg",
34
+ "contributions": [
35
+ "design",
36
+ "review"
37
+ ]
38
+ },
39
+ {
40
+ "login": "rthbound",
41
+ "name": "Ryan T. Hosford",
42
+ "avatar_url": "https://avatars2.githubusercontent.com/u/708692?v=3",
43
+ "profile": "https://github.com/rthbound",
44
+ "contributions": [
45
+ "code"
46
+ ]
47
+ },
48
+ {
49
+ "login": "licatajustin",
50
+ "name": "Justin Licata",
51
+ "avatar_url": "https://avatars0.githubusercontent.com/u/3933204?v=3",
52
+ "profile": "https://twitter.com/justinlicata",
53
+ "contributions": [
54
+ "code",
55
+ "design",
56
+ "doc",
57
+ "review"
58
+ ]
59
+ },
60
+ {
61
+ "login": "dwilkins",
62
+ "name": "David H. Wilkins",
63
+ "avatar_url": "https://avatars2.githubusercontent.com/u/97011?v=3",
64
+ "profile": "http://conecuh.com",
65
+ "contributions": [
66
+ "question",
67
+ "bug",
68
+ "code",
69
+ "design",
70
+ "doc",
71
+ "example",
72
+ "review",
73
+ "test"
74
+ ]
75
+ },
76
+ {
77
+ "login": "TheJayWright",
78
+ "name": "Jay Wright",
79
+ "avatar_url": "https://avatars3.githubusercontent.com/u/19173815?v=3",
80
+ "profile": "https://github.com/TheJayWright",
81
+ "contributions": [
82
+ "review"
83
+ ]
84
+ }
85
+ ]
86
+ }
@@ -3,23 +3,34 @@ require 'active_support/concern'
3
3
  # Squint json, jsonb, hstore queries
4
4
  module Squint
5
5
  extend ActiveSupport::Concern
6
- include ::ActiveRecord::QueryMethods
6
+ if ActiveRecord::VERSION::STRING < '5'
7
+ include ::ActiveRecord::QueryMethods
8
+ end
7
9
 
8
10
  module WhereMethods
9
- # Args may be passed to build_where like:
11
+ # Args may be passed to build/build_where like:
10
12
  # build_where(jsonb_column: {key1: value1})
11
13
  # build_where(jsonb_column: {key1: value1}, jsonb_column: {key2: value2})
12
14
  # build_where(jsonb_column: {key1: value1}, regular_column: value)
13
15
  # build_where(jsonb_column: {key1: value1}, association: {column: value))
14
- def build_where(*args)
16
+ if ActiveRecord::VERSION::STRING > '5'
17
+ method_name = :build
18
+ elsif ActiveRecord::VERSION::STRING < '5'
19
+ method_name = :build_where
20
+ end
21
+ send :define_method, method_name do |*args|
22
+ # For Rails 5, we end up monkey patching WhereClauseFactory for everyone
23
+ # so need to return super if our methods aren't on the AR class
24
+ # doesn't hurt for 4.2.x either
25
+ return super(*args) unless klass.respond_to?(:squint_hash_field_reln)
15
26
  save_args = []
16
27
  reln = args.inject([]) do |memo, arg|
17
28
  if arg.is_a?(Hash)
18
29
  arg.keys.each do |key|
19
30
  if arg[key].is_a?(Hash) && HASH_DATA_COLUMNS[key]
20
- memo << hash_field_reln(key => arg[key])
31
+ memo << klass.squint_hash_field_reln(key => arg[key])
21
32
  else
22
- memo += super(key => arg[key])
33
+ save_args << { key => arg[key] }
23
34
  end
24
35
  end
25
36
  elsif arg.present?
@@ -27,15 +38,51 @@ module Squint
27
38
  end
28
39
  memo
29
40
  end
41
+ if ActiveRecord::VERSION::STRING > '5'
42
+ reln = ActiveRecord::Relation::WhereClause.new(reln, [])
43
+ save_args << [] if save_args.size == 1
44
+ end
30
45
  reln += super(*save_args) unless save_args.empty?
31
46
  reln
32
47
  end
48
+ end
49
+
50
+ included do |base|
51
+ if ActiveRecord::VERSION::STRING < '5'
52
+ ar_reln_module = base::ActiveRecord_Relation
53
+ ar_association_module = base::ActiveRecord_AssociationRelation
54
+ elsif ActiveRecord::VERSION::STRING > '5.1'
55
+ # ActiveRecord_Relation is now a private_constant in 5.1.x
56
+ ar_reln_module = base.relation_delegate_class(ActiveRecord::Relation)::WhereClauseFactory
57
+ ar_association_module = nil
58
+ elsif ActiveRecord::VERSION::STRING > '5.0'
59
+ ar_reln_module = base::ActiveRecord_Relation::WhereClauseFactory
60
+ ar_association_module = nil
61
+ # ar_association_module = base::ActiveRecord_AssociationRelation
62
+ end
63
+
64
+ # put together a list of columns in this model
65
+ # that are hstore, json, or jsonb and will benefit from
66
+ # searchability
67
+ HASH_DATA_COLUMNS = base.columns_hash.keys.map do |col_name|
68
+ if %w[hstore json jsonb].include?(base.columns_hash[col_name].sql_type)
69
+ [col_name.to_sym, base.columns_hash[col_name].sql_type]
70
+ end
71
+ end.compact.to_h
33
72
 
34
- # hash_field_reln
73
+ ar_reln_module.class_eval do
74
+ prepend WhereMethods
75
+ end
76
+
77
+ ar_association_module.try(:class_eval) do
78
+ prepend WhereMethods
79
+ end
80
+
81
+ # squint_hash_field_reln
35
82
  # return an Arel object with the appropriate query
36
83
  # Strings want to be a SQL Literal, other things can be
37
84
  # passed in bare to the eq or in operator
38
- def hash_field_reln(*args)
85
+ def self.squint_hash_field_reln(*args)
39
86
  temp_attr = args[0]
40
87
  contains_nil = false
41
88
  column_type = HASH_DATA_COLUMNS[args[0].keys.first]
@@ -100,109 +147,84 @@ module Squint
100
147
  # specified as a query value
101
148
  if check_attr_missing
102
149
  reln = if column_type == 'hstore'.freeze
103
- hstore_element_missing(column_name_segments, reln)
150
+ squint_hstore_element_missing(column_name_segments, reln)
104
151
  else
105
- jsonb_element_missing(column_name_segments, reln)
152
+ squint_jsonb_element_missing(column_name_segments, reln)
106
153
  end
107
154
  end
108
155
  reln
109
156
  end
110
- end
111
-
112
- included do |base|
113
- ar_reln_module = base::ActiveRecord_Relation
114
- ar_association_module = base::ActiveRecord_AssociationRelation
115
157
 
116
- # put together a list of columns in this model
117
- # that are hstore, json, or jsonb and will benefit from
118
- # searchability
119
- HASH_DATA_COLUMNS = base.columns_hash.keys.map do |col_name|
120
- if %w[hstore json jsonb].include?(base.columns_hash[col_name].sql_type)
121
- [col_name.to_sym, base.columns_hash[col_name].sql_type]
158
+ def self.squint_storext_default?(temp_attr, attribute_sym)
159
+ return false unless respond_to?(:storext_definitions)
160
+ if storext_definitions.keys.include?(attribute_sym) &&
161
+ !(storext_definitions[attribute_sym][:opts] &&
162
+ storext_definitions[attribute_sym][:opts][:default]).nil? &&
163
+ [temp_attr].compact.map(&:to_s).
164
+ flatten.
165
+ include?(storext_definitions[attribute_sym][:opts][:default].to_s)
166
+ true
122
167
  end
123
- end.compact.to_h
124
-
125
- ar_reln_module.class_eval do
126
- prepend WhereMethods
127
168
  end
128
169
 
129
- ar_association_module.class_eval do
130
- prepend WhereMethods
170
+ def self.squint_hstore_element_exists(element, attribute_hash_column, value)
171
+ Arel::Nodes::Equality.new(
172
+ Arel::Nodes::NamedFunction.new(
173
+ "exist",
174
+ [arel_table[Arel::Nodes::SqlLiteral.new(attribute_hash_column)],
175
+ Arel::Nodes::SqlLiteral.new(element)]
176
+ ), value
177
+ )
131
178
  end
132
179
 
133
- def self.jsonb_element_missing(column_name_segments, reln)
180
+ def self.squint_hstore_element_missing(column_name_segments, reln)
134
181
  element = column_name_segments.pop
135
182
  attribute_hash_column = column_name_segments.join('->'.freeze)
136
183
  # Query generated is equals default or attribute present is null or equals false
137
- # * Is null happens when the the whole column is null
184
+ # * Is null happens the the column is null
138
185
  # * equals false is when the column has jsonb data, but the key doesn't exist
139
186
  # ("posts"."storext_attributes"->>'is_awesome' = 'false' OR
140
- # (("posts"."storext_attributes" ? 'is_awesome') IS NULL OR
141
- # ("posts"."storext_attributes" ? 'is_awesome') = FALSE)
187
+ # (exists("posts"."storext_attributes", 'is_awesome') IS NULL OR
188
+ # exists("posts"."storext_attributes", 'is_awesome') = FALSE)
142
189
  # )
143
190
  Arel::Nodes::Grouping.new(
144
191
  reln.or(
145
192
  Arel::Nodes::Grouping.new(
146
- Arel::Nodes::Equality.new(
147
- Arel::Nodes::Grouping.new(
148
- Arel::Nodes::InfixOperation.new(
149
- Arel::Nodes::SqlLiteral.new('?'),
150
- arel_table[Arel::Nodes::SqlLiteral.new(attribute_hash_column)],
151
- Arel::Nodes::SqlLiteral.new(element)
152
- )
153
- ), nil
154
- ).or(
155
- Arel::Nodes::Equality.new(
156
- Arel::Nodes::Grouping.new(
157
- Arel::Nodes::InfixOperation.new(
158
- Arel::Nodes::SqlLiteral.new('?'),
159
- arel_table[Arel::Nodes::SqlLiteral.new(attribute_hash_column)],
160
- Arel::Nodes::SqlLiteral.new(element)
161
- )
162
- ), Arel::Nodes::False.new
163
- )
164
- )
193
+ squint_hstore_element_exists(element, attribute_hash_column, Arel::Nodes::False.new)
194
+ ).or(
195
+ squint_hstore_element_exists(element, attribute_hash_column, nil)
165
196
  )
166
197
  )
167
198
  )
168
199
  end
169
200
 
170
- def self.squint_storext_default?(temp_attr, attribute_sym)
171
- return false unless respond_to?(:storext_definitions)
172
- if storext_definitions.keys.include?(attribute_sym) &&
173
- !storext_definitions[attribute_sym].dig(:opts, :default).nil? &&
174
- [temp_attr].compact.map(&:to_s).
175
- flatten.
176
- include?(storext_definitions[attribute_sym][:opts][:default].to_s)
177
- true
178
- end
201
+ def self.squint_jsonb_element_equality(element, attribute_hash_column, value)
202
+ Arel::Nodes::Equality.new(
203
+ Arel::Nodes::Grouping.new(
204
+ Arel::Nodes::InfixOperation.new(
205
+ Arel::Nodes::SqlLiteral.new('?'),
206
+ arel_table[Arel::Nodes::SqlLiteral.new(attribute_hash_column)],
207
+ Arel::Nodes::SqlLiteral.new(element)
208
+ )
209
+ ), value
210
+ )
179
211
  end
180
212
 
181
- def self.hstore_element_missing(column_name_segments, reln)
213
+ def self.squint_jsonb_element_missing(column_name_segments, reln)
182
214
  element = column_name_segments.pop
183
215
  attribute_hash_column = column_name_segments.join('->'.freeze)
184
216
  # Query generated is equals default or attribute present is null or equals false
185
- # * Is null happens the the column is null
217
+ # * Is null happens when the the whole column is null
186
218
  # * equals false is when the column has jsonb data, but the key doesn't exist
187
219
  # ("posts"."storext_attributes"->>'is_awesome' = 'false' OR
188
- # (exists("posts"."storext_attributes", 'is_awesome') IS NULL OR
189
- # exists("posts"."storext_attributes", 'is_awesome') = FALSE)
220
+ # (("posts"."storext_attributes" ? 'is_awesome') IS NULL OR
221
+ # ("posts"."storext_attributes" ? 'is_awesome') = FALSE)
190
222
  # )
191
223
  Arel::Nodes::Grouping.new(
192
224
  reln.or(
193
225
  Arel::Nodes::Grouping.new(
194
- Arel::Nodes::NamedFunction.new(
195
- "exist",
196
- [arel_table[Arel::Nodes::SqlLiteral.new(attribute_hash_column)],
197
- Arel::Nodes::SqlLiteral.new(element)]
198
- ).eq(Arel::Nodes::False.new)
199
- ).or(
200
- Arel::Nodes::Equality.new(
201
- Arel::Nodes::NamedFunction.new(
202
- "exist",
203
- [arel_table[Arel::Nodes::SqlLiteral.new(attribute_hash_column)],
204
- Arel::Nodes::SqlLiteral.new(element)]
205
- ), nil
226
+ squint_jsonb_element_equality(element, attribute_hash_column, nil).or(
227
+ squint_jsonb_element_equality(element, attribute_hash_column, Arel::Nodes::False.new)
206
228
  )
207
229
  )
208
230
  )
@@ -1,3 +1,3 @@
1
1
  module Squint
2
- VERSION = "0.0.3".freeze
2
+ VERSION = "1.0.0".freeze
3
3
  end
@@ -0,0 +1,185 @@
1
+ <p align="center">
2
+ <a href="https://twitter.com/ProctorUEng">
3
+ <img src="https://s3-us-west-2.amazonaws.com/dev-team-resources/squint-wordmark.svg" width=198 height=72>
4
+ </a>
5
+
6
+ <p align="center">
7
+ Search PostgreSQL <code>jsonb</code> and <code>hstore</code> columns.
8
+ </p>
9
+ </p>
10
+
11
+ <br>
12
+
13
+ > Full database searching inside columns containing semi-structured data like `json`,
14
+ `jsonb` and `hstore`. <strong>Compatible with the awesome
15
+ <a href="https://github.com/G5/storext">storext</a> gem</strong>.
16
+
17
+ ## Table of contents
18
+
19
+ - [Status](#status)
20
+ - [Quick start](#quick-start)
21
+ - [Performance](#performance)
22
+ - [Storext attributes](#storext-attributes)
23
+ - [Developing](#developing)
24
+ - [Contributors](#contributors)
25
+ - [Credits](#credits)
26
+
27
+ ## Status
28
+ [![All Contributors](https://img.shields.io/badge/all_contributors-7-orange.svg?style=flat-square)](#contributors)
29
+ [![CircleCI](https://circleci.com/gh/ProctorU/squint.svg?style=svg)](https://circleci.com/gh/ProctorU/squint)
30
+
31
+ ## Quick Start
32
+
33
+ Add to your Gemfile:
34
+
35
+ ```ruby
36
+ gem 'squint'
37
+ ```
38
+
39
+ Include it in your models:
40
+
41
+ ```ruby
42
+ class Post < ActiveRecord::Base
43
+ include Squint
44
+ # ...
45
+ end
46
+ ```
47
+
48
+ Assuming a table with the following structure:
49
+ ```
50
+ Table "public.posts"
51
+ Column | Type | Modifiers
52
+ ---------------------------+-----------------------------+----------------------------------------------------
53
+ id | integer | not null default nextval('posts_id_seq'::regclass)
54
+ title | character varying |
55
+ body | character varying |
56
+ request_info | jsonb |
57
+ properties | hstore |
58
+ storext_jsonb_attributes | jsonb |
59
+ storext_hstore_attributes | jsonb |
60
+ created_at | timestamp without time zone | not null
61
+ updated_at | timestamp without time zone | not null
62
+ Indexes:
63
+ "posts_pkey" PRIMARY KEY, btree (id)
64
+ ```
65
+
66
+ In your code use queries like:
67
+ ```ruby
68
+ Post.where(properties: { referer: 'http://example.com/one' } )
69
+ # SELECT "posts".* FROM "posts" WHERE "posts"."properties"->'referer' = 'http://example.com/one'
70
+
71
+ Post.where(properties: { referer: nil } )
72
+ # SELECT "posts".* FROM "posts" WHERE "posts"."properties"->'referer' IS NULL
73
+
74
+ Post.where(properties: { referer: ['http://example.com/one',nil] } )
75
+ # SELECT "posts".* FROM "posts" WHERE ("posts"."properties"->'referer' = 'http://example.com/one'
76
+ # OR "posts"."properties"->'referer' IS NULL)
77
+
78
+ Post.where(request_info: { referer: ['http://example.com/one',nil] } )
79
+ # SELECT "posts".* FROM "posts" WHERE ("posts"."request_info"->>'referer' = 'http://example.com/one'
80
+ # OR "posts"."request_info"->>'referer' IS NULL)
81
+ ```
82
+
83
+ Squint only operates on json, jsonb and hstore columns. ActiveRecord
84
+ will throw a StatementInvalid exception like always if the column type is unsupported by
85
+ Squint.
86
+
87
+ ```ruby
88
+ Post.where(title: { not_there: "any value will do" } )
89
+ ```
90
+
91
+ ```
92
+ ActiveRecord::StatementInvalid: PG::UndefinedTable: ERROR: missing FROM-clause entry for table "title"
93
+ LINE 1: SELECT COUNT(*) FROM "posts" WHERE "title"."not_there" = 'an...
94
+ ^
95
+ : SELECT COUNT(*) FROM "posts" WHERE "title"."not_there" = 'any value will do'
96
+ ```
97
+
98
+ ## Performance
99
+ To get the most performance out searching jsonb/hstore attributes, add a GIN (preferred) or
100
+ GIST index to those columns. Find out more
101
+ [here](https://www.postgresql.org/docs/9.5/static/textsearch-indexes.html)
102
+
103
+ TL;DR:
104
+
105
+ SQL: 'CREATE INDEX name ON table USING GIN (column);'
106
+
107
+ Rails Migration: `add_index(:table, :column_name, using: 'gin')`
108
+
109
+
110
+ ## Storext attributes
111
+ Assuming the database schema above and a model like so:
112
+ ```ruby
113
+ class Post < ActiveRecord::Base
114
+ include Storext.model
115
+ include Squint
116
+
117
+ store_attribute :storext_jsonb_attributes, :zip_code, String, default: '90210'
118
+ store_attribute :storext_jsonb_attributes, :friend_count, Integer, default: 0
119
+ end
120
+ ```
121
+
122
+ Example using StoreXT with a default value:
123
+ ```ruby
124
+ Post.where(storext_jsonb_attributes: { zip_code: '90210' } )
125
+ # -- jsonb
126
+ # SELECT "posts".* FROM "posts" WHERE ("posts"."storext_jsonb_attributes"->>'zip_code' = '90210' OR
127
+ # (("posts"."storext_jsonb_attributes" ? 'zip_code') IS NULL OR
128
+ # ("posts"."storext_jsonb_attributes" ? 'zip_code') = FALSE))
129
+ # -- hstore
130
+ # SELECT "posts".* FROM "posts" WHERE ("posts"."storext_hstore_attributes"->'zip_code' = '90210' OR
131
+ # ((exist("posts"."storext_hstore_attributes", 'zip_code') = FALSE) OR
132
+ # exist("posts"."storext_hstore_attributes", 'zip_code') IS NULL))
133
+ #
134
+ #
135
+ ```
136
+ If (as in the example above) the default value for the StoreXT attribute is specified, then extra
137
+ checks for missing column ( `("posts"."storext_jsonb_attributes" ? 'zip_code') IS NULL` ) or
138
+ missing key ( `("posts"."storext_jsonb_attributes" ? 'zip_code') = FALSE)` ) are added
139
+
140
+ When non-default storext values are specified, these extra checks won't be added.
141
+
142
+ The Postgres SQL for jsonb and hstore is different. No support for checking for missing `json`
143
+ columns exists, so don't use those with StoreXT + Squint
144
+
145
+ ## Developing
146
+
147
+ 1. Thank you!
148
+ 1. Clone the repository
149
+ 1. `bundle`
150
+ 1. `bundle exec rake --rakefile test/dummy/Rakefile db:setup` # create the db for tests
151
+ 1. `bundle exec rake` # run the tests
152
+ 1. make your changes in a thoughtfully named branch
153
+ 1. ensure good test coverage
154
+ 1. submit a Pull Request
155
+
156
+ ## Contributors
157
+
158
+ Thanks goes to these wonderful people ([emoji key](https://github.com/kentcdodds/all-contributors#emoji-key)):
159
+
160
+ <!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
161
+ | [<img src="https://avatars2.githubusercontent.com/u/864581?v=3" width="100px;"/><br /><sub>Kevin Brown</sub>](https://github.com/chevinbrown)<br />[🎨](#design-chevinbrown "Design") [👀](#review-chevinbrown "Reviewed Pull Requests") | [<img src="https://avatars2.githubusercontent.com/u/1741179?v=3" width="100px;"/><br /><sub>Andrew Fomera</sub>](http://andrewfomera.com)<br />[👀](#review-king601 "Reviewed Pull Requests") | [<img src="https://avatars2.githubusercontent.com/u/1785682?v=3" width="100px;"/><br /><sub>Matthew Jaeh</sub>](https://github.com/Jaehdawg)<br />[🎨](#design-Jaehdawg "Design") [👀](#review-Jaehdawg "Reviewed Pull Requests") | [<img src="https://avatars2.githubusercontent.com/u/708692?v=3" width="100px;"/><br /><sub>Ryan T. Hosford</sub>](https://github.com/rthbound)<br />[💻](https://github.com/ProctorU/squint/commits?author=rthbound "Code") | [<img src="https://avatars0.githubusercontent.com/u/3933204?v=3" width="100px;"/><br /><sub>Justin Licata</sub>](https://twitter.com/justinlicata)<br />[💻](https://github.com/ProctorU/squint/commits?author=licatajustin "Code") [🎨](#design-licatajustin "Design") [📖](https://github.com/ProctorU/squint/commits?author=licatajustin "Documentation") [👀](#review-licatajustin "Reviewed Pull Requests") | [<img src="https://avatars2.githubusercontent.com/u/97011?v=3" width="100px;"/><br /><sub>David H. Wilkins</sub>](http://conecuh.com)<br />[💬](#question-dwilkins "Answering Questions") [🐛](https://github.com/ProctorU/squint/issues?q=author%3Adwilkins "Bug reports") [💻](https://github.com/ProctorU/squint/commits?author=dwilkins "Code") [🎨](#design-dwilkins "Design") [📖](https://github.com/ProctorU/squint/commits?author=dwilkins "Documentation") [💡](#example-dwilkins "Examples") [👀](#review-dwilkins "Reviewed Pull Requests") [⚠️](https://github.com/ProctorU/squint/commits?author=dwilkins "Tests") | [<img src="https://avatars3.githubusercontent.com/u/19173815?v=3" width="100px;"/><br /><sub>Jay Wright</sub>](https://github.com/TheJayWright)<br />[👀](#review-TheJayWright "Reviewed Pull Requests") |
162
+ | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
163
+ <!-- ALL-CONTRIBUTORS-LIST:END -->
164
+
165
+ This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind welcome!
166
+
167
+ ## Credits
168
+
169
+ Squint is maintained and funded by [ProctorU](https://twitter.com/ProctorUEng).
170
+
171
+ <br>
172
+
173
+ <p align="center">
174
+ <a href="https://twitter.com/ProctorUEng">
175
+ <img src="https://s3-us-west-2.amazonaws.com/dev-team-resources/procki-eyes.svg" width=108 height=72>
176
+ </a>
177
+
178
+ <h3 align="center">
179
+ <a href="https://twitter.com/ProctorUEng">ProctorU Engineering & Design</a>
180
+ </h3>
181
+
182
+ <p align="center">
183
+ A simple online proctoring service that allows you to take exams or certification tests at home.
184
+ </p>
185
+ </p>