Skip to content

Commit d2530e4

Browse files
author
José Valim
committed
Tidy up new record API
1 parent 21b1e51 commit d2530e4

File tree

10 files changed

+284
-28
lines changed

10 files changed

+284
-28
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
* [Exception] `Exception.normalize/1` is deprecated in favor of `Exception.normalize/2`
3636
* [Kernel] `lc` and `bc` comprehensions are deprecated in favor of `for` (this is a soft deprecation, no warning will be emitted)
3737
* [ListDict] `ListDict` is deprecated in favor of `Map` (this is a soft deprecation, no warning will be emitted)
38+
* [Record] `defrecord/2`, `defrecordp/3`, `is_record/1` and `is_record/2` macros in Kernel are deprecated. Instead, use the new macros and API defined in the `Record` module (this is a soft deprecation, no warnings will be emitted)
3839

3940
* Backwards incompatible changes
4041
* [ExUnit] Formatters are now required to be a GenEvent and `ExUnit.run/2` returns a map with results

lib/elixir/lib/file.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
require Record
2+
13
defrecord File.Stat, Record.extract(:file_info, from_lib: "kernel/include/file.hrl") do
24
@moduledoc """
35
A record responsible to hold file information. Its fields are:

lib/elixir/lib/kernel.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3234,6 +3234,9 @@ defmodule Kernel do
32343234
defmacro defstruct(kv) do
32353235
kv = Macro.escape(kv, unquote: true)
32363236
quote bind_quoted: [kv: kv] do
3237+
# Expand possible macros that return KVs.
3238+
kv = Macro.expand(kv, __ENV__)
3239+
32373240
# TODO: Use those types once we support maps typespecs.
32383241
{ fields, _types } = Record.Backend.split_fields_and_types(:defstruct, kv)
32393242

lib/elixir/lib/record.ex

Lines changed: 114 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,35 @@
11
defmodule Record do
2+
@moduledoc """
3+
Module to work, define and import records.
4+
5+
Records are simply tuples where the first element is an atom:
6+
7+
iex> Record.record? { User, "jose", 27 }
8+
true
9+
10+
This module provides conveniences for working with records at
11+
compilation time, where compile-time field names are used to
12+
manipulate the tuples, providing fast operations on top of
13+
the tuples compact structure.
14+
15+
In Elixir, records are used in two situations:
16+
17+
1. To work with short, internal data. See `Inspect.Algebra`
18+
implementation for a good example;
19+
20+
2. To interface with Erlang records;
21+
22+
The macros `defrecord/3` and `defrecordp/3` can be used to create
23+
records while `extract/2` can be used to extract records from Erlang
24+
files.
25+
"""
26+
227
@doc """
328
Extracts record information from an Erlang file.
429
5-
Returns the fields as a list of tuples.
30+
Returns a quoted expression containing the fields as a list
31+
of tuples. It expects the record name to be an atom and the
32+
library path to be a string at expansion time.
633
734
## Examples
835
@@ -13,8 +40,8 @@ defmodule Record do
1340
uid: :undefined, gid: :undefined]
1441
1542
"""
16-
def extract(name, opts) do
17-
Record.Extractor.extract(name, opts)
43+
defmacro extract(name, opts) when is_atom(name) and is_list(opts) do
44+
Macro.escape Record.Extractor.extract(name, opts)
1845
end
1946

2047
@doc """
@@ -24,7 +51,8 @@ defmodule Record do
2451
2552
## Examples
2653
27-
iex> Record.record?({ User, "jose", 27 }, User)
54+
iex> record = { User, "jose", 27 }
55+
iex> Record.record?(record, User)
2856
true
2957
3058
"""
@@ -51,8 +79,10 @@ defmodule Record do
5179
5280
## Examples
5381
54-
iex> Record.record?({ User, "jose", 27 })
82+
iex> record = { User, "jose", 27 }
83+
iex> Record.record?(record)
5584
true
85+
iex> integer = 13
5686
iex> Record.record?(13)
5787
false
5888
@@ -87,4 +117,83 @@ defmodule Record do
87117
def deffunctions(values, env) do
88118
Record.Deprecated.deffunctions(values, env)
89119
end
120+
121+
@doc """
122+
Defines a set of macros to create and access a record.
123+
124+
The macros are going to have `name`, a tag (which defaults)
125+
to the name if none is given, and a set of fields given by
126+
`kv`.
127+
128+
## Examples
129+
130+
defmodule User do
131+
Record.defrecord :user, [name: "José", age: "25"]
132+
end
133+
134+
In the example above, a set of macros named `user` but with different
135+
arities will be defined to manipulate the underlying record:
136+
137+
# To create records
138+
user() #=> { :user, "José", 25 }
139+
user(age: 26) #=> { :user, "José", 26 }
140+
141+
# To get a field from the record
142+
user(record, :name) #=> "José"
143+
144+
# To update the record
145+
user(record, age: 26) #=> { :user, "José", 26 }
146+
147+
By default, Elixir uses the record name as the first element of
148+
the tuple (the tag). But it can be changed to something else:
149+
150+
defmodule User do
151+
Record.defrecord :user, User, name: nil
152+
end
153+
154+
user() #=> { User, nil }
155+
156+
"""
157+
defmacro defrecord(name, tag \\ nil, kv) do
158+
kv = Macro.escape(kv, unquote: true)
159+
160+
quote bind_quoted: [name: name, tag: tag, kv: kv] do
161+
tag = tag || name
162+
kv = Macro.expand(kv, __ENV__)
163+
164+
{ fields, _types } = Record.Backend.split_fields_and_types(:defrecord, kv)
165+
fields = Macro.escape(fields)
166+
167+
defmacro(unquote(name)(args \\ [])) do
168+
Record.Backend.access(unquote(tag), unquote(fields), args, __CALLER__)
169+
end
170+
171+
defmacro(unquote(name)(record, args)) do
172+
Record.Backend.access(unquote(tag), unquote(fields), record, args, __CALLER__)
173+
end
174+
end
175+
end
176+
177+
@doc """
178+
Same as `defrecord/3` but generates private macros.
179+
"""
180+
defmacro defrecordp(name, tag \\ nil, kv) do
181+
kv = Macro.escape(kv, unquote: true)
182+
183+
quote bind_quoted: [name: name, tag: tag, kv: kv] do
184+
tag = tag || name
185+
kv = Macro.expand(kv, __ENV__)
186+
187+
{ fields, _types } = Record.Backend.split_fields_and_types(:defrecordp, kv)
188+
fields = Macro.escape(fields)
189+
190+
defmacrop(unquote(name)(args \\ [])) do
191+
Record.Backend.access(unquote(tag), unquote(fields), args, __CALLER__)
192+
end
193+
194+
defmacrop(unquote(name)(record, args)) do
195+
Record.Backend.access(unquote(tag), unquote(fields), record, args, __CALLER__)
196+
end
197+
end
198+
end
90199
end

lib/elixir/lib/record/backend.ex

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,113 @@ defmodule Record.Backend do
3434
defp split_fields_and_types(_tag, [], fields, types) do
3535
{ :lists.reverse(fields), :lists.reverse(types) }
3636
end
37+
38+
@doc """
39+
Callback invoked from record/0 and record/1 macros.
40+
"""
41+
def access(atom, fields, args, caller) do
42+
cond do
43+
is_atom(args) ->
44+
index(atom, fields, args)
45+
Keyword.keyword?(args) ->
46+
create(atom, fields, args, caller)
47+
true ->
48+
raise ArgumentError,
49+
message: "expected arguments to be a compile time atom or keywords, got: #{Macro.to_string args}"
50+
end
51+
end
52+
53+
@doc """
54+
Callback invoked from the record/2 macro.
55+
"""
56+
def access(atom, fields, record, args, caller) do
57+
cond do
58+
is_atom(args) ->
59+
get(atom, fields, record, args)
60+
Keyword.keyword?(args) ->
61+
update(atom, fields, record, args, caller)
62+
true ->
63+
raise ArgumentError,
64+
message: "expected arguments to be a compile time atom or keywords, got: #{Macro.to_string args}"
65+
end
66+
end
67+
68+
@doc """
69+
Gets the index of field.
70+
"""
71+
def index(atom, fields, field) do
72+
if index = find_index(fields, field, 0) do
73+
index - 1 # Convert to Elixir index
74+
else
75+
raise ArgumentError, message: "record #{inspect atom} does not have the key: #{inspect field}"
76+
end
77+
end
78+
79+
@doc """
80+
Creates a new record with the given default fields and keyword values.
81+
"""
82+
def create(atom, fields, keyword, caller) do
83+
in_match = caller.in_match?
84+
85+
{ match, remaining } =
86+
Enum.map_reduce(fields, keyword, fn({ field, default }, each_keyword) ->
87+
new_fields =
88+
case Keyword.has_key?(each_keyword, field) do
89+
true -> Keyword.get(each_keyword, field)
90+
false ->
91+
case in_match do
92+
true -> { :_, [], nil }
93+
false -> Macro.escape(default)
94+
end
95+
end
96+
97+
{ new_fields, Keyword.delete(each_keyword, field) }
98+
end)
99+
100+
case remaining do
101+
[] ->
102+
{ :{}, [], [atom|match] }
103+
_ ->
104+
keys = for { key, _ } <- remaining, do: key
105+
raise ArgumentError, message: "record #{inspect atom} does not have the key: #{inspect hd(keys)}"
106+
end
107+
end
108+
109+
@doc """
110+
Updates a record given by var with the given keyword.
111+
"""
112+
def update(atom, fields, var, keyword, caller) do
113+
if caller.in_match? do
114+
raise ArgumentError, message: "cannot invoke update style macro inside match"
115+
end
116+
117+
Enum.reduce keyword, var, fn({ key, value }, acc) ->
118+
index = find_index(fields, key, 0)
119+
if index do
120+
quote do
121+
:erlang.setelement(unquote(index), unquote(acc), unquote(value))
122+
end
123+
else
124+
raise ArgumentError, message: "record #{inspect atom} does not have the key: #{inspect key}"
125+
end
126+
end
127+
end
128+
129+
@doc """
130+
Gets a record key from the given var.
131+
"""
132+
def get(atom, fields, var, key) do
133+
index = find_index(fields, key, 0)
134+
if index do
135+
quote do
136+
:erlang.element(unquote(index), unquote(var))
137+
end
138+
else
139+
raise ArgumentError, message: "record #{inspect atom} does not have the key: #{inspect key}"
140+
end
141+
end
142+
143+
defp find_index([{ k, _ }|_], k, i), do: i + 2
144+
defp find_index([{ _, _ }|t], k, i), do: find_index(t, k, i + 1)
145+
defp find_index([], _k, _i), do: nil
37146
end

lib/elixir/test/elixir/deprecated_record/private_test.exs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ Code.require_file "../test_helper.exs", __DIR__
33
defmodule Record.PrivateTest do
44
use ExUnit.Case, async: true
55

6+
require Record
7+
68
defmodule Macros do
79
defrecordp :_user, __MODULE__, name: "José", age: 25
810
defrecordp :_my_user, :my_user, name: "José", age: 25

lib/elixir/test/elixir/deprecated_record/record_test.exs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
Code.require_file "../test_helper.exs", __DIR__
22

3+
require Record
4+
35
defrecord RecordTest.FileInfo,
46
Record.extract(:file_info, from_lib: "kernel/include/file.hrl")
57

lib/elixir/test/elixir/kernel/typespec_test.exs

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -255,28 +255,6 @@ defmodule Kernel.TypespecTest do
255255
assert [{:t, {:type, 251, :map, []}, []}] = types
256256
end
257257

258-
test "@type from records" do
259-
types = test_module do
260-
defrecordp :user, name: nil, age: 0 :: integer
261-
@opaque user :: user_t
262-
@type
263-
end
264-
265-
assert [{:user_t, {:type, _, :tuple,
266-
[{:atom, _, :user}, {:type, _, :term, []}, {:type, _, :integer, []}]}, []}] = types
267-
end
268-
269-
test "@type from records with custom tag" do
270-
{types, module} = test_module do
271-
defrecordp :user, __MODULE__, name: nil, age: 0 :: integer
272-
@opaque user :: user_t
273-
{@type, __MODULE__}
274-
end
275-
276-
assert [{:user_t, {:type, _, :tuple,
277-
[{:atom, _, ^module}, {:type, _, :term, []}, {:type, _, :integer, []}]}, []}] = types
278-
end
279-
280258
test "@type unquote fragment" do
281259
spec = test_module do
282260
quoted = quote unquote: false do

lib/elixir/test/elixir/record_test.exs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@ defmodule RecordTest do
1919
assert elem(namespace, 0) == :xmlNamespace
2020
end
2121

22+
test "extract/2 with defstruct" do
23+
defmodule StructExtract do
24+
defstruct Record.extract(:file_info, from_lib: "kernel/include/file.hrl")
25+
end
26+
27+
assert %{ __struct__: StructExtract, size: :undefined } =
28+
StructExtract.__struct__
29+
end
30+
2231
# We need indirection to avoid warnings
2332
defp record?(data, kind) do
2433
Record.record?(data, kind)
@@ -40,4 +49,36 @@ defmodule RecordTest do
4049
refute record?({ "jose", 27 })
4150
refute record?(13)
4251
end
52+
53+
Record.defrecord :user, __MODULE__, name: "José", age: 25
54+
Record.defrecordp :file_info, Record.extract(:file_info, from_lib: "kernel/include/file.hrl")
55+
56+
test "records generates macros that generates tuples" do
57+
record = user()
58+
assert user(record, :name) == "José"
59+
assert user(record, :age) == 25
60+
61+
record = user(record, name: "Eric")
62+
assert user(record, :name) == "Eric"
63+
64+
assert elem(record, user(:name)) == "Eric"
65+
assert elem(record, 0) == RecordTest
66+
67+
user(name: name) = record
68+
assert name == "Eric"
69+
end
70+
71+
test "records with no tag" do
72+
assert elem(file_info(), 0) == :file_info
73+
end
74+
75+
test "records with dynamic arguments" do
76+
record = file_info()
77+
assert file_info(record, :size) == :undefined
78+
end
79+
80+
test "records visibility" do
81+
assert macro_exported?(__MODULE__, :user, 0)
82+
refute macro_exported?(__MODULE__, :file_info, 1)
83+
end
4384
end

0 commit comments

Comments
 (0)