hquery 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/CHANGELOG.md +133 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE +21 -0
- data/README.md +700 -0
- data/Rakefile +8 -0
- data/lib/hq/context.rb +65 -0
- data/lib/hq/instance.rb +87 -0
- data/lib/hq/page.rb +33 -0
- data/lib/hq/query.rb +306 -0
- data/lib/hq/relations.rb +134 -0
- data/lib/hq/version.rb +3 -0
- data/lib/hq.rb +91 -0
- data/test.md +1466 -0
- metadata +57 -0
data/README.md
ADDED
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
# HashQuery - Advanced Querying & Presentation Layer for Ruby Hashes
|
|
2
|
+
|
|
3
|
+
You know that feeling when you're prototyping and you just need to store some structured data without the ceremony of setting up a database, migrations, and ORMs?
|
|
4
|
+
|
|
5
|
+
Or when you're building a static site and you're tired of wrestling with YAML parsing errors and JSON schema headaches?
|
|
6
|
+
|
|
7
|
+
Meet HashQuery. It's the Ruby library you didn't know you were missing.
|
|
8
|
+
|
|
9
|
+
## The "Aha!" Moment
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
# Instead of this YAML nightmare:
|
|
13
|
+
# data/posts.yml
|
|
14
|
+
# ---
|
|
15
|
+
# - id: 1
|
|
16
|
+
# title: "My Post" # YAML gotcha: this needs quotes
|
|
17
|
+
# published_at: 2024-01-01 # Is this a string? Date? Who knows!
|
|
18
|
+
|
|
19
|
+
# You write pure Ruby (like you wanted all along):
|
|
20
|
+
Hq.define :posts do
|
|
21
|
+
from_array([
|
|
22
|
+
{ id: 1, title: 'Hello World', published: true, created_at: Date.new(2024, 1, 1) },
|
|
23
|
+
{ id: 2, title: 'Ruby Magic', published: false, created_at: Date.new(2024, 1, 15) }
|
|
24
|
+
])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Query it like you always wished you could:
|
|
28
|
+
hq(:posts).where(published: true).order(:created_at, desc: true).first[:title]
|
|
29
|
+
# => "Hello World"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
No schema. No migrations. No database setup. Just Ruby being Ruby.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
# Gemfile
|
|
40
|
+
gem 'joys'
|
|
41
|
+
|
|
42
|
+
# Or if you're feeling spontaneous:
|
|
43
|
+
gem install joys
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quick Start: From Zero to Querying in 30 Seconds
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
require 'hq'
|
|
50
|
+
|
|
51
|
+
# Define your data (arrays, files, whatever)
|
|
52
|
+
Hq.define :posts do
|
|
53
|
+
from_array([
|
|
54
|
+
{ id: 1, title: 'Why I Love Ruby', tags: ['ruby', 'opinion'], views: 1337 },
|
|
55
|
+
{ id: 2, title: 'JavaScript Fatigue', tags: ['js', 'rant'], views: 9001 },
|
|
56
|
+
{ id: 3, title: 'The Perfect Deploy', tags: ['ops', 'ruby'], views: 500 }
|
|
57
|
+
])
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Query like you always wanted to:
|
|
61
|
+
popular_ruby_posts = hq(:posts)
|
|
62
|
+
.where(tags: { contains: 'ruby' })
|
|
63
|
+
.where(views: { greater_than: 1000 })
|
|
64
|
+
.order(:views, desc: true)
|
|
65
|
+
.all
|
|
66
|
+
|
|
67
|
+
puts popular_ruby_posts.first[:title] # => "Why I Love Ruby"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
That global `hq()` helper? It's there because life's too short to type `Hq.query()` every time.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Data Sources: Choose Your Own Adventure
|
|
75
|
+
|
|
76
|
+
### Option 1: Inline Arrays (Perfect for Prototyping)
|
|
77
|
+
|
|
78
|
+
When you just need to get something working:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
Hq.define :users do
|
|
82
|
+
from_array([
|
|
83
|
+
{ id: 1, name: 'Alice', role: 'admin', coffee_preference: 'black' },
|
|
84
|
+
{ id: 2, name: 'Bob', role: 'user', coffee_preference: 'latte' },
|
|
85
|
+
{ id: 3, name: 'Carol', role: 'editor', coffee_preference: 'espresso' }
|
|
86
|
+
])
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Option 2: File-Based Data (The Grown-Up Way)
|
|
91
|
+
|
|
92
|
+
Your data files are just Ruby scripts that return hashes. No parsing, no gotchas:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
# data/posts/001-hello-world.rb
|
|
96
|
+
{
|
|
97
|
+
id: 1,
|
|
98
|
+
title: 'Hello World',
|
|
99
|
+
slug: 'hello-world',
|
|
100
|
+
published_at: Date.new(2024, 1, 1),
|
|
101
|
+
tags: ['ruby', 'beginnings'],
|
|
102
|
+
meta: {
|
|
103
|
+
author: 'You',
|
|
104
|
+
reading_time: '2 min'
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
# data/posts/002-advanced-stuff.rb
|
|
109
|
+
{
|
|
110
|
+
id: 2,
|
|
111
|
+
title: 'Advanced Ruby Wizardry',
|
|
112
|
+
slug: 'advanced-ruby-wizardry',
|
|
113
|
+
published_at: Date.new(2024, 1, 15),
|
|
114
|
+
tags: ['ruby', 'advanced'],
|
|
115
|
+
meta: {
|
|
116
|
+
author: 'You (but smarter)',
|
|
117
|
+
reading_time: '15 min'
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
Hq.define :posts do
|
|
124
|
+
load 'posts/*.rb' # Loads in sorted filename order
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Want a different data directory? No problem:
|
|
128
|
+
Hq.configure(data_path: 'content/data')
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Pro tip: Your editor will syntax highlight these files, catch typos, and even provide autocomplete. Try doing that with YAML.
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## Scopes: Make Your Queries Reusable
|
|
136
|
+
|
|
137
|
+
Remember writing the same `.where()` chains over and over? Those days are over:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
Hq.define :posts do
|
|
141
|
+
from_array([...]) # Your data
|
|
142
|
+
|
|
143
|
+
scope({
|
|
144
|
+
published: -> { where(published: true) },
|
|
145
|
+
featured: -> { where(featured: true) },
|
|
146
|
+
recent: ->(n=10) { order(:published_at, desc: true).limit(n) },
|
|
147
|
+
tagged: ->(tag) { where(tags: { contains: tag }) },
|
|
148
|
+
popular: -> { where(views: { greater_than: 100 }).order(:views, desc: true) }
|
|
149
|
+
})
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Now your queries read like English:
|
|
153
|
+
trending_ruby_posts = hq(:posts).published.tagged('ruby').popular.recent(5)
|
|
154
|
+
featured_content = hq(:posts).published.featured.recent
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Scopes are chainable, composable, and parameterizable. They're basically custom query methods that don't suck.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Querying: The Good Stuff
|
|
162
|
+
|
|
163
|
+
HashQuery provides a fluent, chainable API for filtering and sorting your data. Queries are lazy—they only execute when you ask for the results (e.g., by calling `.first`, `.map`, `.to_a`, `.each`, `.size`).
|
|
164
|
+
|
|
165
|
+
### Basic Queries (The Classics)
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
posts = hq(:posts) # Get the query builder for :posts
|
|
169
|
+
|
|
170
|
+
# Get results
|
|
171
|
+
posts.to_a # Get all matching records as an array of Hq::InstanceWrapper objects
|
|
172
|
+
posts.first # First match or nil
|
|
173
|
+
posts.last # Last match (respects order) or nil
|
|
174
|
+
posts.count # Fast count of matching records
|
|
175
|
+
|
|
176
|
+
# Find by specific attribute(s)
|
|
177
|
+
posts.find_by(slug: 'hello-world') # Convenience for where(slug: '...').first
|
|
178
|
+
posts.find_by(id: 1)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**Note:** You no longer need to call `.all`. The query object itself acts like an array when you iterate (`.each`, `.map`) or access its size (`.size`, `.count`, `.length`). Use `.to_a` if you specifically need a plain `Array`.
|
|
182
|
+
|
|
183
|
+
### Where Clauses (AND Logic)
|
|
184
|
+
|
|
185
|
+
Chain multiple `.where` calls or provide multiple conditions in a hash to combine filters with `AND`.
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
# Find published posts tagged 'ruby'
|
|
189
|
+
hq(:posts).where(published: true).where(tags: { contains: 'ruby' })
|
|
190
|
+
|
|
191
|
+
# Equivalent using a single hash
|
|
192
|
+
hq(:posts).where(published: true, tags: { contains: 'ruby' })
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### OR Logic (`or_where`)
|
|
196
|
+
|
|
197
|
+
Use `.or_where` to add conditions combined with `OR`.
|
|
198
|
+
|
|
199
|
+
```ruby
|
|
200
|
+
# Find posts that are featured OR have more than 1000 views
|
|
201
|
+
hq(:posts).where(featured: true).or_where(views: { greater_than: 1000 })
|
|
202
|
+
# => WHERE featured = true OR views > 1000
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Grouping Conditions with Blocks
|
|
206
|
+
|
|
207
|
+
Use blocks with `where` and `or_where` to create nested logical groups. Conditions inside a block are implicitly joined by `AND` unless `or_where` is used within that block.
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
# Find posts where (author_id = 1 AND published = true)
|
|
211
|
+
hq(:posts).where do |q|
|
|
212
|
+
q.where(author_id: 1).where(published: true)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Find posts where (author_id = 1 AND (published = true OR featured = true))
|
|
216
|
+
hq(:posts).where(author_id: 1).where do |q|
|
|
217
|
+
q.where(published: true).or_where(featured: true)
|
|
218
|
+
end
|
|
219
|
+
# => WHERE author_id = 1 AND (published = true OR featured = true)
|
|
220
|
+
|
|
221
|
+
# Find posts where (author_id = 1 AND published = true) OR (views > 1000)
|
|
222
|
+
hq(:posts).where { |q| q.where(author_id: 1).where(published: true) }
|
|
223
|
+
.or_where(views: { greater_than: 1000 })
|
|
224
|
+
# => WHERE (author_id = 1 AND published = true) OR views > 1000
|
|
225
|
+
|
|
226
|
+
# Find posts where (author_id = 1 AND published = true) OR (author_id = 2 AND featured = true)
|
|
227
|
+
hq(:posts).where { |q| q.where(author_id: 1).where(published: true) }
|
|
228
|
+
.or_where { |q| q.where(author_id: 2).where(featured: true) }
|
|
229
|
+
# => WHERE (author_id = 1 AND published = true) OR (author_id = 2 AND featured = true)
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### Operators & Negation (Hash Conditions)
|
|
233
|
+
|
|
234
|
+
Use hash conditions within `where` or `or_where` for powerful comparisons and negation.
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
# Text searches
|
|
238
|
+
hq(:posts).where(title: { contains: 'Ruby' })
|
|
239
|
+
hq(:posts).where(title: { starts_with: 'How to' })
|
|
240
|
+
hq(:posts).where(title: { ends_with: '101' })
|
|
241
|
+
|
|
242
|
+
# Numeric comparisons
|
|
243
|
+
hq(:posts).where(views: { greater_than: 1000 })
|
|
244
|
+
hq(:posts).where(views: { less_than_or_equal: 500 })
|
|
245
|
+
hq(:posts).where(score: { from: 7.5, to: 9.0 }) # Inclusive range
|
|
246
|
+
hq(:posts).where(views: 100..500) # Ruby Range works too
|
|
247
|
+
|
|
248
|
+
# Existence and emptiness
|
|
249
|
+
hq(:posts).where(featured_image: { exists: true }) # Key is present and not nil
|
|
250
|
+
hq(:posts).where(featured_image: { exists: false }) # Key is missing or nil
|
|
251
|
+
hq(:posts).where(tags: { empty: false }) # Not nil, not '', not []
|
|
252
|
+
hq(:posts).where(tags: { empty: true }) # Is nil, '', or []
|
|
253
|
+
|
|
254
|
+
# Array / Set operations
|
|
255
|
+
hq(:posts).where(id: { in: [1, 3, 5] }) # Value is one of these
|
|
256
|
+
hq(:posts).where(id: [1, 3, 5]) # Shortcut for :in
|
|
257
|
+
hq(:posts).where(status: { not_in: ['draft', 'archived'] }) # Value is NOT one of these
|
|
258
|
+
hq(:posts).where(tags: ['ruby', 'web']) # Exact array match (order matters)
|
|
259
|
+
|
|
260
|
+
# Negation
|
|
261
|
+
hq(:posts).where(published: { not: true }) # Value is not true (false or nil)
|
|
262
|
+
hq(:posts).where(title: { not: 'Hello' }) # Value is not 'Hello'
|
|
263
|
+
hq(:posts).where(views: { not: nil }) # Same as { exists: true }
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### Ordering (Nil-Safe and Type-Aware)
|
|
267
|
+
|
|
268
|
+
Sort your results using `.order`. Nils are always sorted last.
|
|
269
|
+
|
|
270
|
+
```ruby
|
|
271
|
+
# Ascending (default)
|
|
272
|
+
hq(:posts).order(:published_at)
|
|
273
|
+
|
|
274
|
+
# Descending
|
|
275
|
+
hq(:posts).order(:views, desc: true)
|
|
276
|
+
|
|
277
|
+
# Strings sort naturally
|
|
278
|
+
hq(:posts).order(:title)
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Mixed types? No problem. HashQuery handles the type coercion so you don't have to think about it during sorting.
|
|
282
|
+
|
|
283
|
+
-----
|
|
284
|
+
### Limiting and Pagination
|
|
285
|
+
|
|
286
|
+
```ruby
|
|
287
|
+
# Classic pagination
|
|
288
|
+
hq(:posts).limit(10) # First 10
|
|
289
|
+
hq(:posts).offset(20).limit(10) # Items 21-30
|
|
290
|
+
|
|
291
|
+
# Modern pagination with metadata
|
|
292
|
+
pages = hq(:posts).order(:created_at, desc: true).paginate(per_page: 5)
|
|
293
|
+
|
|
294
|
+
page = pages.first
|
|
295
|
+
page.items # Array of posts for this page
|
|
296
|
+
page.current_page # 1
|
|
297
|
+
page.total_pages # 4
|
|
298
|
+
page.total_items # 18
|
|
299
|
+
page.next_page # 2 (or nil if last page)
|
|
300
|
+
page.prev_page # nil (or previous page number)
|
|
301
|
+
page.is_first_page # true
|
|
302
|
+
page.is_last_page # false
|
|
303
|
+
|
|
304
|
+
# Perfect for building pagination UI
|
|
305
|
+
pages.each do |page|
|
|
306
|
+
puts "Page #{page.current_page}: #{page.items.size} posts"
|
|
307
|
+
end
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
The Page object has everything you need for pagination UI without any mental math.
|
|
311
|
+
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
## Instance Wrappers: Hash-Like, But Better
|
|
315
|
+
|
|
316
|
+
Results aren't plain hashes—they're immutable wrappers that feel like hashes but prevent accidents:
|
|
317
|
+
|
|
318
|
+
```ruby
|
|
319
|
+
post = hq(:posts).first
|
|
320
|
+
|
|
321
|
+
# Access like a hash (symbol or string keys both work)
|
|
322
|
+
post[:title] # => "Hello World"
|
|
323
|
+
post['title'] # => "Hello World" (same thing)
|
|
324
|
+
|
|
325
|
+
# All the hash methods you expect
|
|
326
|
+
post.keys # => [:id, :title, :content, ...]
|
|
327
|
+
post.size # => 5
|
|
328
|
+
post.has_key?(:id) # => true
|
|
329
|
+
post.include?('title') # => true
|
|
330
|
+
post.to_h # Raw hash if you need it
|
|
331
|
+
|
|
332
|
+
# But immutable (this raises an error)
|
|
333
|
+
post[:title] = 'New Title' # RuntimeError: collections are immutable
|
|
334
|
+
|
|
335
|
+
# Custom methods work too
|
|
336
|
+
post.excerpt(20) # Your custom methods
|
|
337
|
+
post.reading_time # Computed properties
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
It's like getting a hash that went to finishing school.
|
|
341
|
+
|
|
342
|
+
---
|
|
343
|
+
|
|
344
|
+
## Development Helpers (For Your Sanity)
|
|
345
|
+
|
|
346
|
+
```ruby
|
|
347
|
+
# Reload everything during development
|
|
348
|
+
Hq.reload
|
|
349
|
+
|
|
350
|
+
# See what models you've defined
|
|
351
|
+
Hq.defined_models # => [:posts, :users, :tags]
|
|
352
|
+
|
|
353
|
+
# Use the explicit API when you need it
|
|
354
|
+
Hq.query(:posts).where(...) # Same as hq(:posts).where(...)
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
Error messages are actually helpful:
|
|
358
|
+
- Syntax errors in data files show the exact file and line
|
|
359
|
+
- Missing files tell you the exact pattern that failed
|
|
360
|
+
- Type errors explain what was expected vs. what was found
|
|
361
|
+
|
|
362
|
+
---
|
|
363
|
+
|
|
364
|
+
## Relationships: Connecting Your Data
|
|
365
|
+
|
|
366
|
+
HashQuery makes it easy to define relationships between your data collections, similar to ActiveRecord associations. Define them right inside your `Hq.define` block.
|
|
367
|
+
|
|
368
|
+
### Defining Relationships
|
|
369
|
+
|
|
370
|
+
Use `belongs_to`, `has_many`, and `has_one` to link your data. HashQuery uses conventions for keys but allows overrides.
|
|
371
|
+
|
|
372
|
+
```ruby
|
|
373
|
+
# --- Authors ---
|
|
374
|
+
Hq.define :authors do
|
|
375
|
+
from_array([
|
|
376
|
+
{ id: 1, name: 'Alice' },
|
|
377
|
+
{ id: 2, name: 'Bob' }
|
|
378
|
+
])
|
|
379
|
+
|
|
380
|
+
# Author has many posts (looks for :author_id in :posts)
|
|
381
|
+
has_many :posts
|
|
382
|
+
|
|
383
|
+
# Author has one profile (looks for :author_id in :profiles)
|
|
384
|
+
has_one :profile
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# --- Posts ---
|
|
388
|
+
Hq.define :posts do
|
|
389
|
+
from_array([
|
|
390
|
+
{ id: 101, title: 'Intro to Hq', author_id: 1 },
|
|
391
|
+
{ id: 102, title: 'Advanced Ruby', author_id: 1 },
|
|
392
|
+
{ id: 103, title: 'Web Development', author_id: 2 }
|
|
393
|
+
])
|
|
394
|
+
|
|
395
|
+
# Post belongs to an author (looks for :author_id here, links to :authors using :id)
|
|
396
|
+
belongs_to :author
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# --- Profiles ---
|
|
400
|
+
Hq.define :profiles do
|
|
401
|
+
from_array([
|
|
402
|
+
{ profile_id: 201, bio: 'Ruby Developer', author_id: 1 }, # Note: primary key is :profile_id
|
|
403
|
+
{ profile_id: 202, bio: 'Web Enthusiast', author_id: 2 }
|
|
404
|
+
])
|
|
405
|
+
|
|
406
|
+
# Profile belongs to an author (looks for :author_id here)
|
|
407
|
+
# Target model (:authors) uses :id as primary key by default
|
|
408
|
+
belongs_to :author
|
|
409
|
+
end
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
### Accessing Relationships
|
|
413
|
+
|
|
414
|
+
Access related data using simple dot notation on your `Hq::InstanceWrapper` objects.
|
|
415
|
+
|
|
416
|
+
```ruby
|
|
417
|
+
alice = hq(:authors).find_by(id: 1)
|
|
418
|
+
post = hq(:posts).find_by(id: 101)
|
|
419
|
+
profile = hq(:profiles).find_by(profile_id: 201)
|
|
420
|
+
|
|
421
|
+
# Belongs To (returns InstanceWrapper or nil)
|
|
422
|
+
author_name = post.author.name
|
|
423
|
+
# => "Alice"
|
|
424
|
+
author_bio = profile.author.profile.bio # Chain through relations
|
|
425
|
+
# => "Ruby Developer"
|
|
426
|
+
|
|
427
|
+
# Has One (returns InstanceWrapper or nil)
|
|
428
|
+
alices_bio = alice.profile.bio
|
|
429
|
+
# => "Ruby Developer"
|
|
430
|
+
|
|
431
|
+
# Has Many (returns a Hq::Query object)
|
|
432
|
+
alices_posts = alice.posts
|
|
433
|
+
# => <Hq::Query @model_name=:posts ...>
|
|
434
|
+
|
|
435
|
+
# You can chain queries on has_many results
|
|
436
|
+
published_titles = alice.posts.where(published: true).map { |p| p.title }
|
|
437
|
+
# => ["Intro to Hq"]
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### Overriding Conventions
|
|
441
|
+
|
|
442
|
+
Need custom keys or class names? No problem.
|
|
443
|
+
|
|
444
|
+
```ruby
|
|
445
|
+
Hq.define :users do
|
|
446
|
+
from_array([{ user_pk: 501, username: 'admin' }]) # Custom primary key
|
|
447
|
+
|
|
448
|
+
# Specify foreign_key in posts (:creator_id) and owner_key here (:user_pk)
|
|
449
|
+
has_many :articles, class_name: :posts, foreign_key: :creator_id, owner_key: :user_pk
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
Hq.define :posts do
|
|
453
|
+
# ... other posts ...
|
|
454
|
+
from_array([{ id: 105, title: 'Admin Post', creator_id: 501 }]) # Custom foreign key
|
|
455
|
+
|
|
456
|
+
# Specify foreign_key here (:creator_id) and primary_key on users (:user_pk)
|
|
457
|
+
belongs_to :creator, class_name: :users, foreign_key: :creator_id, primary_key: :user_pk
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
admin = hq(:users).first
|
|
461
|
+
admin_article_title = admin.articles.first.title
|
|
462
|
+
# => "Admin Post"
|
|
463
|
+
|
|
464
|
+
post = hq(:posts).find_by(id: 105)
|
|
465
|
+
creator_name = post.creator.username
|
|
466
|
+
# => "admin"
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
### Many-to-Many (`has_many :through`)
|
|
470
|
+
|
|
471
|
+
Define the intermediate `has_many` first, then the `through` relationship.
|
|
472
|
+
|
|
473
|
+
```ruby
|
|
474
|
+
Hq.define :posts do
|
|
475
|
+
# ... (fields, belongs_to :author) ...
|
|
476
|
+
has_many :post_tags # Link to the join collection
|
|
477
|
+
has_many :tags, through: :post_tags # Go through :post_tags to find :tags
|
|
478
|
+
# (infers :tag on PostTag model)
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
Hq.define :tags do
|
|
482
|
+
from_array([ { id: 301, name: 'ruby' }, { id: 302, name: 'web' } ])
|
|
483
|
+
has_many :post_tags
|
|
484
|
+
has_many :posts, through: :post_tags # Go through :post_tags to find :posts
|
|
485
|
+
# (infers :post on PostTag model)
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
Hq.define :post_tags do # The join collection
|
|
489
|
+
from_array([
|
|
490
|
+
{ id: 401, post_id: 101, tag_id: 301 },
|
|
491
|
+
{ id: 402, post_id: 101, tag_id: 302 },
|
|
492
|
+
{ id: 403, post_id: 102, tag_id: 301 }
|
|
493
|
+
])
|
|
494
|
+
belongs_to :post # Link back to posts
|
|
495
|
+
belongs_to :tag # Link back to tags
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# Usage:
|
|
499
|
+
post = hq(:posts).find_by(id: 101)
|
|
500
|
+
tag_names = post.tags.map { |t| t.name }
|
|
501
|
+
# => ["ruby", "web"]
|
|
502
|
+
|
|
503
|
+
tag = hq(:tags).find_by(name: 'ruby')
|
|
504
|
+
post_titles = tag.posts.map { |p| p.title }
|
|
505
|
+
# => ["Intro to Hq", "Advanced Ruby"]
|
|
506
|
+
|
|
507
|
+
# Override source if needed:
|
|
508
|
+
# has_many :categories, through: :post_categories, source: :category
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
Relationships in HashQuery provide a powerful yet simple way to navigate your connected data directly within Ruby.
|
|
512
|
+
|
|
513
|
+
---
|
|
514
|
+
|
|
515
|
+
## Presenters: Adding Behavior to Your Data
|
|
516
|
+
|
|
517
|
+
While `Hq` focuses on querying, you often need helper methods for formatting or deriving information from your data records. Instead of cluttering your view logic, you can define these directly using `Hq.present`. This replaces the older, less ergonomic `item` block.
|
|
518
|
+
|
|
519
|
+
### Defining Presenter Methods
|
|
520
|
+
|
|
521
|
+
Use standard Ruby `def` syntax within a `Hq.present` block associated with your model name. Inside these methods, `self` refers to the `Hq::InstanceWrapper` object, allowing you to access the underlying data using `self[:key]` or just `key` (if the key doesn't clash with a method name).
|
|
522
|
+
|
|
523
|
+
```ruby
|
|
524
|
+
Hq.define :posts do
|
|
525
|
+
from_array([
|
|
526
|
+
{ id: 1, title: 'Hello World', slug: 'hello-world', content: 'This is the first post.', published_at: Date.today }
|
|
527
|
+
])
|
|
528
|
+
belongs_to :author # Example relation
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# Define presenter methods for the :posts model
|
|
532
|
+
Hq.present :posts do
|
|
533
|
+
# Simple formatting
|
|
534
|
+
def formatted_date
|
|
535
|
+
self[:published_at]&.strftime('%B %d, %Y') # Access data with self[:key]
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# Derived data
|
|
539
|
+
def url
|
|
540
|
+
"/posts/#{slug}/" # Access data directly via dynamically defined method 'slug'
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
# Methods with arguments
|
|
544
|
+
def excerpt(word_count = 25)
|
|
545
|
+
words = content.split # Access data via 'content' method
|
|
546
|
+
return content if words.size <= word_count
|
|
547
|
+
words.take(word_count).join(' ') + '...'
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
# Accessing relations within presenters
|
|
551
|
+
def author_name
|
|
552
|
+
author&.name || 'Anonymous' # Call the 'author' relation method
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
### Using Presenter Methods
|
|
558
|
+
|
|
559
|
+
Presenter methods are automatically available directly on the `InstanceWrapper` objects returned by your queries.
|
|
560
|
+
|
|
561
|
+
```ruby
|
|
562
|
+
post = hq(:posts).first
|
|
563
|
+
|
|
564
|
+
# Access hash data keys
|
|
565
|
+
puts post.id # => 1
|
|
566
|
+
puts post[:title] # => "Hello World" (Hash access still works)
|
|
567
|
+
|
|
568
|
+
# Call presenter methods
|
|
569
|
+
puts post.url # => "/posts/hello-world/"
|
|
570
|
+
puts post.formatted_date # => "October 27, 2025"
|
|
571
|
+
puts post.excerpt(3) # => "This is the..."
|
|
572
|
+
puts post.author_name # => (Assuming author relation works) "Alice" or "Anonymous"
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
### Benefits
|
|
576
|
+
|
|
577
|
+
* **Clean Ruby Syntax:** Uses standard `def`
|
|
578
|
+
* **Clear Separation:** Keeps presentation logic separate from the core data definition (`Hq.define`).
|
|
579
|
+
* **Precedence:** Presenter methods will override accessor methods created for hash keys if they share the same name. Access the original hash value using `self[:key]` if needed.
|
|
580
|
+
|
|
581
|
+
Use `Hq.present` to keep your data definitions clean and add reusable display logic directly to your data objects.
|
|
582
|
+
|
|
583
|
+
---
|
|
584
|
+
|
|
585
|
+
## Auto-Loading Data Files
|
|
586
|
+
|
|
587
|
+
Instead of manually requiring data files, use `Hq.load_from` to automatically discover and load all data definitions:
|
|
588
|
+
|
|
589
|
+
```ruby
|
|
590
|
+
# Directory structure:
|
|
591
|
+
# data/
|
|
592
|
+
# users.rb
|
|
593
|
+
# posts.rb
|
|
594
|
+
# categories.rb
|
|
595
|
+
|
|
596
|
+
# config.ru or boot file
|
|
597
|
+
require 'hq'
|
|
598
|
+
|
|
599
|
+
Hq.load_from('data') # Loads all .rb files in data/
|
|
600
|
+
|
|
601
|
+
# Now all collections are available
|
|
602
|
+
hq(:users).where(active: true)
|
|
603
|
+
hq(:posts).order(:created_at)
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
**Development mode reloading:**
|
|
607
|
+
|
|
608
|
+
```ruby
|
|
609
|
+
if ENV['RACK_ENV'] == 'development'
|
|
610
|
+
require 'listen'
|
|
611
|
+
|
|
612
|
+
listener = Listen.to('data') do |modified, added, removed|
|
|
613
|
+
puts "Data changed, reloading..."
|
|
614
|
+
Hq.reload_from('data')
|
|
615
|
+
end
|
|
616
|
+
listener.start
|
|
617
|
+
end
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
Files are loaded in alphabetical order. Use nested directories to organize large data sets:
|
|
621
|
+
|
|
622
|
+
```ruby
|
|
623
|
+
data/
|
|
624
|
+
blog/
|
|
625
|
+
posts.rb
|
|
626
|
+
categories.rb
|
|
627
|
+
users/
|
|
628
|
+
admins.rb
|
|
629
|
+
customers.rb
|
|
630
|
+
|
|
631
|
+
Hq.load_from('data') # Loads everything recursively
|
|
632
|
+
```
|
|
633
|
+
|
|
634
|
+
---
|
|
635
|
+
|
|
636
|
+
## Performance Notes (For the Curious)
|
|
637
|
+
|
|
638
|
+
**The Good News:** Hq is fast enough for most use cases. We're talking thousands of items with complex queries in milliseconds.
|
|
639
|
+
|
|
640
|
+
**The Technical Details:**
|
|
641
|
+
- Everything lives in memory (no I/O after initial load)
|
|
642
|
+
- Queries are lazy—filters only apply when you call `.all`, `.first`, etc.
|
|
643
|
+
- Data is frozen at load time (immutable and thread-safe)
|
|
644
|
+
- Method results are memoized automatically
|
|
645
|
+
- Under 500 lines of core code (minimal overhead)
|
|
646
|
+
|
|
647
|
+
**Sweet Spot:** Hundreds to low thousands of items. Perfect for:
|
|
648
|
+
- Blog posts and pages
|
|
649
|
+
- Product catalogs
|
|
650
|
+
- Team directories
|
|
651
|
+
- Configuration data
|
|
652
|
+
- Documentation sites
|
|
653
|
+
- Prototype datasets
|
|
654
|
+
|
|
655
|
+
**When to Graduate to a Real Database:**
|
|
656
|
+
- Tens of thousands of items
|
|
657
|
+
- Real-time updates needed
|
|
658
|
+
- Complex relationships and joins
|
|
659
|
+
- Multi-user concurrent writes
|
|
660
|
+
|
|
661
|
+
---
|
|
662
|
+
|
|
663
|
+
## When Hq Shines
|
|
664
|
+
|
|
665
|
+
**Perfect For:**
|
|
666
|
+
- Static site content management
|
|
667
|
+
- Rapid prototyping with structured data
|
|
668
|
+
- Configuration and settings management
|
|
669
|
+
- Small-to-medium datasets that don't change often
|
|
670
|
+
- Replacing hand-rolled JSON/YAML parsers
|
|
671
|
+
- When you want SQL-like querying without SQL complexity
|
|
672
|
+
|
|
673
|
+
**Not Ideal For:**
|
|
674
|
+
- Large-scale data (stick with SQLite/PostgreSQL/MySQL)
|
|
675
|
+
- Real-time analytics
|
|
676
|
+
- Data that changes frequently
|
|
677
|
+
- Multi-table joins and complex relationships
|
|
678
|
+
- When you actually need ACID transactions
|
|
679
|
+
|
|
680
|
+
---
|
|
681
|
+
|
|
682
|
+
## The Philosophy
|
|
683
|
+
|
|
684
|
+
We built Hq because we were tired of the false choice between "simple but limited" and "powerful but complex."
|
|
685
|
+
|
|
686
|
+
Why should prototyping with structured data require setting up a database? Why should static sites need a complex build pipeline just to query some content? Why can't data files be as expressive as the rest of our Ruby code?
|
|
687
|
+
|
|
688
|
+
Hq is our answer: a data layer that grows with your project. Start with arrays, move to files, add scopes and methods as needed. When you outgrow it, you'll have learned exactly what you need from a real database.
|
|
689
|
+
|
|
690
|
+
It's the tool we always wished existed. Now it does.
|
|
691
|
+
|
|
692
|
+
---
|
|
693
|
+
|
|
694
|
+
**Ready to feel the joy of simple, powerful data?**
|
|
695
|
+
|
|
696
|
+
```bash
|
|
697
|
+
gem install hq
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
Your future self will thank you.
|